@crombolo/server 0.0.0-stage → 0.10.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 (85) hide show
  1. package/LICENSE +21 -0
  2. package/NOTICE +69 -0
  3. package/package.json +41 -4
  4. package/src/access.ts +114 -0
  5. package/src/adapters/assistant.ts +229 -0
  6. package/src/adapters/blueprints.ts +163 -0
  7. package/src/adapters/bundles.ts +507 -0
  8. package/src/adapters/content.ts +783 -0
  9. package/src/adapters/dictionary.ts +76 -0
  10. package/src/adapters/documents.ts +637 -0
  11. package/src/adapters/elements.ts +403 -0
  12. package/src/adapters/file-system.ts +195 -0
  13. package/src/adapters/media.ts +347 -0
  14. package/src/adapters/member-groups.ts +90 -0
  15. package/src/adapters/members.ts +261 -0
  16. package/src/adapters/public-access.ts +116 -0
  17. package/src/adapters/redirects.ts +73 -0
  18. package/src/adapters/references.ts +67 -0
  19. package/src/adapters/schema-files.ts +204 -0
  20. package/src/adapters/schema-proposals.ts +180 -0
  21. package/src/adapters/template-files.ts +58 -0
  22. package/src/adapters/template-query.ts +127 -0
  23. package/src/adapters/users.ts +963 -0
  24. package/src/assistant.ts +487 -0
  25. package/src/backup.ts +52 -0
  26. package/src/bundle-install.ts +417 -0
  27. package/src/bundle-sections.ts +424 -0
  28. package/src/bundle-tables.ts +161 -0
  29. package/src/bundles.ts +609 -0
  30. package/src/component-snippets.ts +163 -0
  31. package/src/components.ts +266 -0
  32. package/src/config.ts +556 -0
  33. package/src/content-type-udt.ts +256 -0
  34. package/src/database.ts +148 -0
  35. package/src/diagnostics.ts +143 -0
  36. package/src/dictionary-transfer.ts +123 -0
  37. package/src/domains.ts +384 -0
  38. package/src/editor-types.ts +108 -0
  39. package/src/email-http.ts +79 -0
  40. package/src/email-notice.ts +36 -0
  41. package/src/email-postmark.ts +78 -0
  42. package/src/email-resend.ts +69 -0
  43. package/src/email-ses.ts +307 -0
  44. package/src/email.ts +402 -0
  45. package/src/extensions-notice.ts +35 -0
  46. package/src/file-intake.ts +84 -0
  47. package/src/form-files.ts +241 -0
  48. package/src/form-submit.ts +397 -0
  49. package/src/form-workflows.ts +397 -0
  50. package/src/forms.ts +170 -0
  51. package/src/git-github.ts +171 -0
  52. package/src/git-routes.ts +154 -0
  53. package/src/git.ts +88 -0
  54. package/src/imaging.ts +434 -0
  55. package/src/index.ts +65 -0
  56. package/src/jobs.ts +140 -0
  57. package/src/listen.ts +36 -0
  58. package/src/log-expressions.ts +524 -0
  59. package/src/log-viewer.ts +190 -0
  60. package/src/logging.ts +126 -0
  61. package/src/marketplace.ts +382 -0
  62. package/src/media-files.ts +276 -0
  63. package/src/media-store-azure.ts +218 -0
  64. package/src/media-store-s3.ts +113 -0
  65. package/src/media-store.ts +248 -0
  66. package/src/member-auth.ts +384 -0
  67. package/src/models-builder.ts +106 -0
  68. package/src/no-nodes.ts +64 -0
  69. package/src/not-implemented.ts +44 -0
  70. package/src/oembed.ts +144 -0
  71. package/src/ports.ts +553 -0
  72. package/src/redirects.ts +132 -0
  73. package/src/schema-classify.ts +82 -0
  74. package/src/schema-store.ts +145 -0
  75. package/src/schema.ts +388 -0
  76. package/src/seo.ts +371 -0
  77. package/src/server-events.ts +131 -0
  78. package/src/server.ts +1642 -0
  79. package/src/status.ts +275 -0
  80. package/src/tar.ts +85 -0
  81. package/src/udt.ts +168 -0
  82. package/src/value-schemas.ts +64 -0
  83. package/src/view-runtime.ts +42 -0
  84. package/src/zip.ts +152 -0
  85. package/README.md +0 -3
@@ -0,0 +1,403 @@
1
+ /**
2
+ * The Library section: Umbraco 18's Elements.
3
+ *
4
+ * An element is a node facet exactly as a document is — `node` + `content` +
5
+ * `content_version` — so versioning, publishing and the recycle bin are machinery
6
+ * that already exists and the document repository takes `element` as a fifth
7
+ * `kind`. What an element does not have is a URL or a template: it is content
8
+ * something else points at, so there is no route to resolve and no view to pick.
9
+ *
10
+ * Its folders are plain `node` rows with the ElementContainer object type, carried
11
+ * by the generic folder repository, which is why they worked before elements
12
+ * themselves did.
13
+ */
14
+ import type { ElementPort, Principal } from '@crombolo/api-management'
15
+ import { type ElementTreeItem, normaliseUuid, ObjectTypes, SystemNodes } from '@crombolo/core'
16
+ import {
17
+ appendCacheInstruction,
18
+ type Db,
19
+ DocumentRepository,
20
+ FolderRepository,
21
+ NodeRepository,
22
+ type NodeRow,
23
+ type NodeSchemaState,
24
+ PublishBlockedError,
25
+ type ValueIntake,
26
+ WriteRejectedError,
27
+ } from '@crombolo/data'
28
+ import { TemporaryFileMissingError } from '../file-intake.ts'
29
+ import { createFolderPort, createSettingsTree } from './content.ts'
30
+
31
+ export function createElementPort(
32
+ db: Db,
33
+ options: {
34
+ nodeState?: NodeSchemaState
35
+ nodeId?: string
36
+ valueIntake?: ValueIntake
37
+ /** Told after every change, so pages that resolved an element refresh. */
38
+ onChange?: () => void | Promise<void>
39
+ } = {},
40
+ ): ElementPort {
41
+ const repo = new DocumentRepository(db, { ...options, kind: 'element' })
42
+ const nodes = new NodeRepository(db)
43
+ const folders = new FolderRepository(db, ObjectTypes.ElementContainer, ObjectTypes.Element)
44
+
45
+ const changed = async () => {
46
+ await appendCacheInstruction(db, {
47
+ kind: 'content',
48
+ payload: { elements: true },
49
+ by: options.nodeId,
50
+ })
51
+ await options.onChange?.()
52
+ }
53
+ const tree = createSettingsTree(nodes, ObjectTypes.ElementContainer, ObjectTypes.Element, changed)
54
+
55
+ const userIdOf = async (principal: Principal): Promise<number | undefined> => {
56
+ const rows = await db.query<{ id: number }>('SELECT id FROM user_account WHERE key = ?', [
57
+ principal.id,
58
+ ])
59
+ return rows[0] ? Number(rows[0].id) : undefined
60
+ }
61
+ const notFound = (reason = 'The element could not be found.') => ({
62
+ ok: false as const,
63
+ reason,
64
+ status: 404,
65
+ })
66
+ const failure = (error: unknown) => {
67
+ if (error instanceof WriteRejectedError)
68
+ return { ok: false as const, reason: error.message, status: 409 }
69
+ if (error instanceof PublishBlockedError) return { ok: false as const, reason: error.message }
70
+ if (error instanceof TemporaryFileMissingError)
71
+ return { ok: false as const, reason: error.message, status: 400 }
72
+ throw error
73
+ }
74
+ /** An element has no template, whatever a save sends. */
75
+ const saved = <T extends object>(input: T) => ({ ...input, componentKey: null })
76
+
77
+ return {
78
+ folders: createFolderPort(folders, tree, changed),
79
+
80
+ // The Library tree mixes folders and elements, so these read `node` rows for
81
+ // both object types and shape each accordingly, rather than using the generic
82
+ // settings tree: an element has to carry its type and its variants' publish
83
+ // state, which a folder has none of.
84
+ treeRoot: (paging) => treePage(null, paging),
85
+ treeChildren: (parentKey, paging) => treePage(parentKey, paging),
86
+ async ancestors(descendantKey) {
87
+ const rows = (await nodes.ancestors(descendantKey)).filter(
88
+ (row) =>
89
+ row.objectType === ObjectTypes.Element || row.objectType === ObjectTypes.ElementContainer,
90
+ )
91
+ return shape(rows)
92
+ },
93
+ async items(keys) {
94
+ const rows = await Promise.all(keys.map((key) => nodes.byKey(key)))
95
+ return shape(
96
+ rows.filter(
97
+ (row): row is NodeRow =>
98
+ row?.objectType === ObjectTypes.Element ||
99
+ row?.objectType === ObjectTypes.ElementContainer,
100
+ ),
101
+ )
102
+ },
103
+ async treeSiblings(target, before, after) {
104
+ const window = await nodes.siblings(
105
+ target,
106
+ [ObjectTypes.ElementContainer, ObjectTypes.Element],
107
+ before,
108
+ after,
109
+ )
110
+ if (!window) return undefined
111
+ return { ...window, items: await shape(window.items) }
112
+ },
113
+
114
+ byKey: (key) => repo.byKey(key),
115
+ byKeyPublished: (key) => repo.byKeyPublished(key),
116
+ validate: (input, cultures) => repo.validate(input, cultures),
117
+
118
+ async create(input, principal) {
119
+ try {
120
+ const created = await repo.create(saved({ ...input, userId: await userIdOf(principal) }))
121
+ await changed()
122
+ return { ok: true as const, key: created.key }
123
+ } catch (error) {
124
+ return failure(error)
125
+ }
126
+ },
127
+
128
+ async update(key, input, principal) {
129
+ try {
130
+ const updated = await repo.update(
131
+ saved({ ...input, key, userId: await userIdOf(principal) }),
132
+ )
133
+ if (!updated) return notFound()
134
+ await changed()
135
+ return { ok: true as const }
136
+ } catch (error) {
137
+ return failure(error)
138
+ }
139
+ },
140
+
141
+ async createAndPublish(input, cultures, principal) {
142
+ try {
143
+ const result = await repo.createAndPublish(
144
+ saved({ ...input, userId: await userIdOf(principal) }),
145
+ cultures,
146
+ )
147
+ if (!result.ok)
148
+ return {
149
+ ok: false as const,
150
+ reason: 'One or more properties did not pass validation',
151
+ status: 400,
152
+ errors: result.errors,
153
+ }
154
+ await changed()
155
+ return { ok: true as const, key: result.document.key }
156
+ } catch (error) {
157
+ return failure(error)
158
+ }
159
+ },
160
+
161
+ async updateAndPublish(key, input, cultures, principal) {
162
+ try {
163
+ const result = await repo.updateAndPublish(
164
+ saved({ ...input, key, userId: await userIdOf(principal) }),
165
+ cultures,
166
+ )
167
+ if (!result) return notFound()
168
+ if (!result.ok)
169
+ return {
170
+ ok: false as const,
171
+ reason: 'One or more properties did not pass validation',
172
+ status: 400,
173
+ errors: result.errors,
174
+ }
175
+ await changed()
176
+ return { ok: true as const }
177
+ } catch (error) {
178
+ return failure(error)
179
+ }
180
+ },
181
+
182
+ async publish(key, cultures) {
183
+ try {
184
+ const published = await repo.publish(key, cultures)
185
+ if (!published) return notFound()
186
+ await changed()
187
+ return { ok: true as const }
188
+ } catch (error) {
189
+ return failure(error)
190
+ }
191
+ },
192
+
193
+ async unpublish(key, cultures) {
194
+ try {
195
+ const result = await repo.unpublish(key, cultures)
196
+ if (!result) return notFound()
197
+ await changed()
198
+ return { ok: true as const }
199
+ } catch (error) {
200
+ return failure(error)
201
+ }
202
+ },
203
+
204
+ async copy(key, targetKey, copyOptions, principal) {
205
+ try {
206
+ const copied = await repo.copy(key, targetKey, {
207
+ includeDescendants: copyOptions.includeDescendants,
208
+ userId: await userIdOf(principal),
209
+ })
210
+ // The repository answers the new key, or undefined when either end is missing.
211
+ if (!copied) return notFound('The element or target could not be found.')
212
+ await changed()
213
+ return { ok: true as const, key: copied }
214
+ } catch (error) {
215
+ return failure(error)
216
+ }
217
+ },
218
+
219
+ async move(key, targetKey) {
220
+ const result = await repo.move(key, targetKey)
221
+ if (result === 'moved') {
222
+ await changed()
223
+ return { ok: true as const }
224
+ }
225
+ return result === 'notFound'
226
+ ? notFound('The element or target could not be found.')
227
+ : {
228
+ ok: false as const,
229
+ reason: 'An element cannot be moved below itself or into the recycle bin.',
230
+ status: 400,
231
+ }
232
+ },
233
+
234
+ async moveToRecycleBin(key) {
235
+ const moved = await repo.moveToRecycleBin(key)
236
+ if (moved) await changed()
237
+ return moved
238
+ },
239
+
240
+ async remove(key) {
241
+ const removed = await repo.delete(key)
242
+ if (removed) await changed()
243
+ return removed
244
+ },
245
+
246
+ async restore(key, targetKey) {
247
+ const result = await repo.restore(key, targetKey)
248
+ if (result === 'restored') {
249
+ await changed()
250
+ return { ok: true as const }
251
+ }
252
+ return result === 'notFound'
253
+ ? notFound('The element is not in the recycle bin.')
254
+ : {
255
+ ok: false as const,
256
+ reason: 'The restore target does not exist or is in the recycle bin.',
257
+ status: 400,
258
+ }
259
+ },
260
+
261
+ async emptyRecycleBin() {
262
+ for (const node of await repo.trashedRoots()) {
263
+ if (await repo.delete(node.key)) await changed()
264
+ }
265
+ },
266
+
267
+ originalParent: (key) => repo.originalParent(key),
268
+
269
+ async recycleBin(parentKey, paging) {
270
+ const page = await repo.trashed(parentKey, paging.skip, paging.take)
271
+ return { total: page.total, items: await shape(page.items) }
272
+ },
273
+
274
+ async recycleBinSiblings(target, before, after) {
275
+ const window = await repo.siblings(target, before, after)
276
+ if (!window) return undefined
277
+ return { ...window, items: await shape(window.items) }
278
+ },
279
+
280
+ async search(query, searchOptions) {
281
+ const page = await repo.search(query, searchOptions)
282
+ return { total: page.total, items: await shape(page.items) }
283
+ },
284
+
285
+ versions: (key) => repo.versions(key),
286
+ atVersion: (versionId) => repo.atVersion(versionId),
287
+ async rollback(versionId) {
288
+ const result = await repo.rollback(versionId)
289
+ if (result) await changed()
290
+ return result !== undefined
291
+ },
292
+ setPreventCleanup: (versionId, prevent) => repo.setPreventCleanup(versionId, prevent),
293
+
294
+ // The document repository's move refuses anything that is not an element, so a
295
+ // folder moves through the node repository instead, with the same two rules:
296
+ // the target must be a folder, and nothing may move inside itself.
297
+ async moveFolder(key, targetKey) {
298
+ const node = await nodes.byKey(key)
299
+ if (!node || node.objectType !== ObjectTypes.ElementContainer)
300
+ return notFound('The folder could not be found.')
301
+ const target = targetKey ? await nodes.byKey(targetKey) : undefined
302
+ if (targetKey && (!target || target.objectType !== ObjectTypes.ElementContainer))
303
+ return notFound('The target folder could not be found.')
304
+ if (target && (target.id === node.id || `${target.path},`.startsWith(`${node.path},`)))
305
+ return {
306
+ ok: false as const,
307
+ reason: 'A folder cannot be moved below itself.',
308
+ status: 400,
309
+ }
310
+ await nodes.move(node.id, target?.id ?? SystemNodes.Root)
311
+ await changed()
312
+ return { ok: true as const }
313
+ },
314
+
315
+ async folderToRecycleBin(key) {
316
+ const node = await nodes.byKey(key)
317
+ if (!node || node.objectType !== ObjectTypes.ElementContainer) return false
318
+ await nodes.move(node.id, SystemNodes.ElementRecycleBin)
319
+ // The whole branch is trashed, elements inside it included, and an element
320
+ // stays unpublished while it is in the bin.
321
+ const moved = await nodes.byKey(key)
322
+ await db.exec('UPDATE node SET trashed = ? WHERE id = ? OR path LIKE ?', [
323
+ db.dialect.boolValue(true),
324
+ node.id,
325
+ `${moved?.path ?? node.path},%`,
326
+ ])
327
+ await db.exec(
328
+ `UPDATE document SET published = ?, edited = ?
329
+ WHERE node_id IN (SELECT id FROM node WHERE id = ? OR path LIKE ?)`,
330
+ [
331
+ db.dialect.boolValue(false),
332
+ db.dialect.boolValue(true),
333
+ node.id,
334
+ `${moved?.path ?? node.path},%`,
335
+ ],
336
+ )
337
+ await changed()
338
+ return true
339
+ },
340
+
341
+ async folderItems(keys) {
342
+ const rows = await Promise.all(keys.map((key) => nodes.byKey(key)))
343
+ return shape(
344
+ rows.filter((row): row is NodeRow => row?.objectType === ObjectTypes.ElementContainer),
345
+ )
346
+ },
347
+ async systemUserKey() {
348
+ const rows = await db.query<{ key: string }>(
349
+ 'SELECT key FROM user_account ORDER BY id LIMIT 1',
350
+ )
351
+ return rows[0] ? normaliseUuid(String(rows[0].key)) : null
352
+ },
353
+ }
354
+
355
+ /** Elements and folders as tree rows; a folder reports no type and no content. */
356
+ async function shape(rows: readonly NodeRow[]): Promise<ElementTreeItem[]> {
357
+ const items: ElementTreeItem[] = []
358
+ for (const row of rows) {
359
+ const isFolder = row.objectType === ObjectTypes.ElementContainer
360
+ const parent = row.parentId > 0 ? await nodes.byId(row.parentId) : undefined
361
+ const parentKey =
362
+ parent && parent.objectType !== ObjectTypes.ElementRecycleBin ? parent.key : null
363
+ if (isFolder) {
364
+ const inside = await nodes.childrenOf(
365
+ row.id,
366
+ [ObjectTypes.ElementContainer, ObjectTypes.Element],
367
+ 0,
368
+ 1,
369
+ )
370
+ items.push({
371
+ key: row.key,
372
+ name: row.text ?? '',
373
+ hasChildren: inside.total > 0,
374
+ parentKey,
375
+ contentTypeKey: '',
376
+ icon: 'icon-folder',
377
+ isTrashed: row.trashed,
378
+ createDate: row.createDate ?? new Date(),
379
+ ancestorKeys: [],
380
+ variants: [{ culture: null, name: row.text ?? '', state: 'NotCreated' }],
381
+ isFolder: true,
382
+ })
383
+ continue
384
+ }
385
+ const [item] = await repo.treeItems([row])
386
+ if (item) items.push({ ...item, parentKey, isFolder: false })
387
+ }
388
+ return items
389
+ }
390
+
391
+ async function treePage(
392
+ parentKey: string | null,
393
+ paging: { skip: number; take: number; foldersOnly?: boolean },
394
+ ) {
395
+ const parentId = parentKey ? (await nodes.byKey(parentKey))?.id : SystemNodes.Root
396
+ if (parentId === undefined) return { total: 0, items: [] }
397
+ const types = paging.foldersOnly
398
+ ? [ObjectTypes.ElementContainer]
399
+ : [ObjectTypes.ElementContainer, ObjectTypes.Element]
400
+ const result = await nodes.childrenOf(parentId, types, paging.skip, paging.take)
401
+ return { total: result.total, items: await shape(result.items) }
402
+ }
403
+ }
@@ -0,0 +1,195 @@
1
+ /**
2
+ * A file-system area on disk — components, stylesheets or scripts — as the
3
+ * Management API sees it: paths relative to the area's root, which never
4
+ * escape it, and only files of the area's extension.
5
+ */
6
+ import { existsSync } from 'node:fs'
7
+ import { mkdir, readdir, readFile, rename, rm, rmdir, stat, writeFile } from 'node:fs/promises'
8
+ import { join } from 'node:path'
9
+ import type { FileEntry, FileResult, FileSystemPort } from '@crombolo/api-management'
10
+
11
+ export interface FileAreaOptions {
12
+ /** The extension files are stored with, e.g. `.css`. */
13
+ extension: string
14
+ /** Extensions a name may arrive with that are stored as `extension` instead. */
15
+ rewrites?: readonly string[]
16
+ }
17
+
18
+ const INVALID_CHARACTERS = /[<>:"|?*\\/]/
19
+
20
+ /** A name no file system objects to: no path characters and no control characters. */
21
+ const invalidName = (name: string) =>
22
+ INVALID_CHARACTERS.test(name) || [...name].some((c) => (c.codePointAt(0) ?? 0) < 0x20)
23
+
24
+ /** A path's segments, or undefined when it is not a plain path below the root. */
25
+ function segmentsOf(path: string | null): string[] | undefined {
26
+ if (path === null) return []
27
+ const trimmed = path.replace(/^\/+|\/+$/g, '')
28
+ if (trimmed === '') return []
29
+ const segments = trimmed.split('/')
30
+ return segments.every((s) => s !== '' && s !== '.' && s !== '..' && !s.includes('\\'))
31
+ ? segments
32
+ : undefined
33
+ }
34
+
35
+ const toPath = (segments: readonly string[]) => `/${segments.join('/')}`
36
+
37
+ export function createFileSystemPort(root: string, options: FileAreaOptions): FileSystemPort {
38
+ const extension = options.extension.toLowerCase()
39
+ const locate = (path: string | null) => {
40
+ const segments = segmentsOf(path)
41
+ return segments ? { segments, file: join(root, ...segments) } : undefined
42
+ }
43
+ const kindOf = async (file: string): Promise<'file' | 'folder' | undefined> => {
44
+ try {
45
+ const info = await stat(file)
46
+ return info.isDirectory() ? 'folder' : info.isFile() ? 'file' : undefined
47
+ } catch {
48
+ return undefined
49
+ }
50
+ }
51
+ const visible = (name: string, isFolder: boolean) =>
52
+ !name.startsWith('.') && (isFolder || name.toLowerCase().endsWith(extension))
53
+
54
+ /** The name as stored: with the area's extension, whichever accepted one it came with. */
55
+ const fileName = (name: string): string | 'InvalidName' | 'InvalidFileExtension' => {
56
+ const trimmed = name.trim()
57
+ if (!trimmed || trimmed.startsWith('.') || invalidName(trimmed)) return 'InvalidName'
58
+ const lower = trimmed.toLowerCase()
59
+ if (lower.endsWith(extension)) return trimmed
60
+ const rewrite = options.rewrites?.find((r) => lower.endsWith(r.toLowerCase()))
61
+ if (rewrite) return `${trimmed.slice(0, -rewrite.length)}${options.extension}`
62
+ return 'InvalidFileExtension'
63
+ }
64
+
65
+ const entryAt = async (segments: string[]): Promise<FileEntry | undefined> => {
66
+ const file = join(root, ...segments)
67
+ const kind = await kindOf(file)
68
+ const name = segments[segments.length - 1]
69
+ if (!kind || !name || !visible(name, kind === 'folder')) return undefined
70
+ const parent = segments.slice(0, -1)
71
+ return {
72
+ path: toPath(segments),
73
+ name,
74
+ parentPath: parent.length > 0 ? toPath(parent) : null,
75
+ isFolder: kind === 'folder',
76
+ hasChildren:
77
+ kind === 'folder' &&
78
+ (await readdir(file, { withFileTypes: true })).some((d) =>
79
+ visible(d.name, d.isDirectory()),
80
+ ),
81
+ }
82
+ }
83
+
84
+ const parentFolder = async (parentPath: string | null) => {
85
+ const parent = locate(parentPath)
86
+ if (!parent) return undefined
87
+ if (parent.segments.length === 0) {
88
+ await mkdir(root, { recursive: true })
89
+ return parent
90
+ }
91
+ return (await kindOf(parent.file)) === 'folder' ? parent : undefined
92
+ }
93
+
94
+ return {
95
+ async children(parentPath) {
96
+ const parent = locate(parentPath)
97
+ if (!parent) return undefined
98
+ if (parent.segments.length === 0 && !existsSync(root)) return []
99
+ if ((await kindOf(parent.file)) !== 'folder') return undefined
100
+ const entries: FileEntry[] = []
101
+ for (const dirent of await readdir(parent.file, { withFileTypes: true })) {
102
+ const entry = await entryAt([...parent.segments, dirent.name])
103
+ if (entry) entries.push(entry)
104
+ }
105
+ return entries.sort(
106
+ (a, b) =>
107
+ Number(b.isFolder) - Number(a.isFolder) ||
108
+ a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }),
109
+ )
110
+ },
111
+
112
+ async entry(path) {
113
+ const found = locate(path)
114
+ return found && found.segments.length > 0 ? entryAt(found.segments) : undefined
115
+ },
116
+
117
+ async read(path) {
118
+ const found = locate(path)
119
+ if (!found || found.segments.length === 0) return undefined
120
+ const entry = await entryAt(found.segments)
121
+ if (!entry || entry.isFolder) return undefined
122
+ return {
123
+ path: entry.path,
124
+ name: entry.name,
125
+ parentPath: entry.parentPath,
126
+ content: await readFile(found.file, 'utf8'),
127
+ }
128
+ },
129
+
130
+ async create(parentPath, name, content): Promise<FileResult> {
131
+ const stored = fileName(name)
132
+ if (stored === 'InvalidName' || stored === 'InvalidFileExtension')
133
+ return { ok: false, status: stored }
134
+ const parent = await parentFolder(parentPath)
135
+ if (!parent) return { ok: false, status: 'ParentNotFound' }
136
+ const segments = [...parent.segments, stored]
137
+ const file = join(root, ...segments)
138
+ if (existsSync(file)) return { ok: false, status: 'AlreadyExists' }
139
+ await writeFile(file, content, 'utf8')
140
+ return { ok: true, path: toPath(segments) }
141
+ },
142
+
143
+ async update(path, content): Promise<FileResult> {
144
+ const found = locate(path)
145
+ const entry = found && found.segments.length > 0 ? await entryAt(found.segments) : undefined
146
+ if (!found || !entry || entry.isFolder) return { ok: false, status: 'NotFound' }
147
+ await writeFile(found.file, content, 'utf8')
148
+ return { ok: true, path: entry.path }
149
+ },
150
+
151
+ async rename(path, name): Promise<FileResult> {
152
+ const found = locate(path)
153
+ const entry = found && found.segments.length > 0 ? await entryAt(found.segments) : undefined
154
+ if (!found || !entry || entry.isFolder) return { ok: false, status: 'NotFound' }
155
+ const stored = fileName(name)
156
+ if (stored === 'InvalidName' || stored === 'InvalidFileExtension')
157
+ return { ok: false, status: stored }
158
+ const segments = [...found.segments.slice(0, -1), stored]
159
+ const target = join(root, ...segments)
160
+ if (target !== found.file && existsSync(target)) return { ok: false, status: 'AlreadyExists' }
161
+ await rename(found.file, target)
162
+ return { ok: true, path: toPath(segments) }
163
+ },
164
+
165
+ async delete(path): Promise<FileResult> {
166
+ const found = locate(path)
167
+ const entry = found && found.segments.length > 0 ? await entryAt(found.segments) : undefined
168
+ if (!found || !entry || entry.isFolder) return { ok: false, status: 'NotFound' }
169
+ await rm(found.file)
170
+ return { ok: true, path: entry.path }
171
+ },
172
+
173
+ async createFolder(parentPath, name): Promise<FileResult> {
174
+ const trimmed = name.trim()
175
+ if (!trimmed || trimmed.startsWith('.') || invalidName(trimmed))
176
+ return { ok: false, status: 'InvalidName' }
177
+ const parent = await parentFolder(parentPath)
178
+ if (!parent) return { ok: false, status: 'ParentNotFound' }
179
+ const segments = [...parent.segments, trimmed]
180
+ const folder = join(root, ...segments)
181
+ if (existsSync(folder)) return { ok: false, status: 'AlreadyExists' }
182
+ await mkdir(folder)
183
+ return { ok: true, path: toPath(segments) }
184
+ },
185
+
186
+ async deleteFolder(path): Promise<FileResult> {
187
+ const found = locate(path)
188
+ const entry = found && found.segments.length > 0 ? await entryAt(found.segments) : undefined
189
+ if (!found || !entry?.isFolder) return { ok: false, status: 'NotFound' }
190
+ if ((await readdir(found.file)).length > 0) return { ok: false, status: 'NotEmpty' }
191
+ await rmdir(found.file)
192
+ return { ok: true, path: entry.path }
193
+ },
194
+ }
195
+ }