@nuasite/cms 0.1.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 (269) hide show
  1. package/README.md +237 -0
  2. package/dist/src/build-processor.d.ts +20 -0
  3. package/dist/src/build-processor.d.ts.map +1 -0
  4. package/dist/src/collection-scanner.d.ts +6 -0
  5. package/dist/src/collection-scanner.d.ts.map +1 -0
  6. package/dist/src/component-registry.d.ts +63 -0
  7. package/dist/src/component-registry.d.ts.map +1 -0
  8. package/dist/src/config.d.ts +24 -0
  9. package/dist/src/config.d.ts.map +1 -0
  10. package/dist/src/dev-middleware.d.ts +20 -0
  11. package/dist/src/dev-middleware.d.ts.map +1 -0
  12. package/dist/src/editor/ai.d.ts +60 -0
  13. package/dist/src/editor/ai.d.ts.map +1 -0
  14. package/dist/src/editor/api.d.ts +140 -0
  15. package/dist/src/editor/api.d.ts.map +1 -0
  16. package/dist/src/editor/color-utils.d.ts +106 -0
  17. package/dist/src/editor/color-utils.d.ts.map +1 -0
  18. package/dist/src/editor/components/ai-chat.d.ts +11 -0
  19. package/dist/src/editor/components/ai-chat.d.ts.map +1 -0
  20. package/dist/src/editor/components/ai-tooltip.d.ts +12 -0
  21. package/dist/src/editor/components/ai-tooltip.d.ts.map +1 -0
  22. package/dist/src/editor/components/attribute-editor.d.ts +5 -0
  23. package/dist/src/editor/components/attribute-editor.d.ts.map +1 -0
  24. package/dist/src/editor/components/block-editor.d.ts +12 -0
  25. package/dist/src/editor/components/block-editor.d.ts.map +1 -0
  26. package/dist/src/editor/components/collections-browser.d.ts +2 -0
  27. package/dist/src/editor/components/collections-browser.d.ts.map +1 -0
  28. package/dist/src/editor/components/color-toolbar.d.ts +12 -0
  29. package/dist/src/editor/components/color-toolbar.d.ts.map +1 -0
  30. package/dist/src/editor/components/confirm-dialog.d.ts +2 -0
  31. package/dist/src/editor/components/confirm-dialog.d.ts.map +1 -0
  32. package/dist/src/editor/components/create-page-modal.d.ts +2 -0
  33. package/dist/src/editor/components/create-page-modal.d.ts.map +1 -0
  34. package/dist/src/editor/components/editable-highlights.d.ts +9 -0
  35. package/dist/src/editor/components/editable-highlights.d.ts.map +1 -0
  36. package/dist/src/editor/components/error-boundary.d.ts +32 -0
  37. package/dist/src/editor/components/error-boundary.d.ts.map +1 -0
  38. package/dist/src/editor/components/fields.d.ts +75 -0
  39. package/dist/src/editor/components/fields.d.ts.map +1 -0
  40. package/dist/src/editor/components/frontmatter-fields.d.ts +29 -0
  41. package/dist/src/editor/components/frontmatter-fields.d.ts.map +1 -0
  42. package/dist/src/editor/components/highlight-overlay.d.ts +64 -0
  43. package/dist/src/editor/components/highlight-overlay.d.ts.map +1 -0
  44. package/dist/src/editor/components/image-overlay.d.ts +12 -0
  45. package/dist/src/editor/components/image-overlay.d.ts.map +1 -0
  46. package/dist/src/editor/components/markdown-editor-overlay.d.ts +6 -0
  47. package/dist/src/editor/components/markdown-editor-overlay.d.ts.map +1 -0
  48. package/dist/src/editor/components/markdown-inline-editor.d.ts +10 -0
  49. package/dist/src/editor/components/markdown-inline-editor.d.ts.map +1 -0
  50. package/dist/src/editor/components/media-library.d.ts +2 -0
  51. package/dist/src/editor/components/media-library.d.ts.map +1 -0
  52. package/dist/src/editor/components/outline.d.ts +21 -0
  53. package/dist/src/editor/components/outline.d.ts.map +1 -0
  54. package/dist/src/editor/components/redirect-countdown.d.ts +2 -0
  55. package/dist/src/editor/components/redirect-countdown.d.ts.map +1 -0
  56. package/dist/src/editor/components/seo-editor.d.ts +2 -0
  57. package/dist/src/editor/components/seo-editor.d.ts.map +1 -0
  58. package/dist/src/editor/components/text-style-toolbar.d.ts +8 -0
  59. package/dist/src/editor/components/text-style-toolbar.d.ts.map +1 -0
  60. package/dist/src/editor/components/toast/toast-container.d.ts +7 -0
  61. package/dist/src/editor/components/toast/toast-container.d.ts.map +1 -0
  62. package/dist/src/editor/components/toast/toast.d.ts +7 -0
  63. package/dist/src/editor/components/toast/toast.d.ts.map +1 -0
  64. package/dist/src/editor/components/toast/types.d.ts +7 -0
  65. package/dist/src/editor/components/toast/types.d.ts.map +1 -0
  66. package/dist/src/editor/components/toolbar.d.ts +21 -0
  67. package/dist/src/editor/components/toolbar.d.ts.map +1 -0
  68. package/dist/src/editor/config.d.ts +4 -0
  69. package/dist/src/editor/config.d.ts.map +1 -0
  70. package/dist/src/editor/constants.d.ts +101 -0
  71. package/dist/src/editor/constants.d.ts.map +1 -0
  72. package/dist/src/editor/context.d.ts +14 -0
  73. package/dist/src/editor/context.d.ts.map +1 -0
  74. package/dist/src/editor/dom.d.ts +77 -0
  75. package/dist/src/editor/dom.d.ts.map +1 -0
  76. package/dist/src/editor/editor.d.ts +64 -0
  77. package/dist/src/editor/editor.d.ts.map +1 -0
  78. package/dist/src/editor/history.d.ts +20 -0
  79. package/dist/src/editor/history.d.ts.map +1 -0
  80. package/dist/src/editor/hooks/index.d.ts +14 -0
  81. package/dist/src/editor/hooks/index.d.ts.map +1 -0
  82. package/dist/src/editor/hooks/useAIHandlers.d.ts +22 -0
  83. package/dist/src/editor/hooks/useAIHandlers.d.ts.map +1 -0
  84. package/dist/src/editor/hooks/useBlockEditorHandlers.d.ts +18 -0
  85. package/dist/src/editor/hooks/useBlockEditorHandlers.d.ts.map +1 -0
  86. package/dist/src/editor/hooks/useElementDetection.d.ts +26 -0
  87. package/dist/src/editor/hooks/useElementDetection.d.ts.map +1 -0
  88. package/dist/src/editor/hooks/useImageHoverDetection.d.ts +12 -0
  89. package/dist/src/editor/hooks/useImageHoverDetection.d.ts.map +1 -0
  90. package/dist/src/editor/hooks/useTextSelection.d.ts +23 -0
  91. package/dist/src/editor/hooks/useTextSelection.d.ts.map +1 -0
  92. package/dist/src/editor/hooks/useTooltipState.d.ts +19 -0
  93. package/dist/src/editor/hooks/useTooltipState.d.ts.map +1 -0
  94. package/dist/src/editor/hooks/utils.d.ts +32 -0
  95. package/dist/src/editor/hooks/utils.d.ts.map +1 -0
  96. package/dist/src/editor/index.d.ts +12 -0
  97. package/dist/src/editor/index.d.ts.map +1 -0
  98. package/dist/src/editor/lib/cn.d.ts +3 -0
  99. package/dist/src/editor/lib/cn.d.ts.map +1 -0
  100. package/dist/src/editor/manifest.d.ts +19 -0
  101. package/dist/src/editor/manifest.d.ts.map +1 -0
  102. package/dist/src/editor/markdown-api.d.ts +36 -0
  103. package/dist/src/editor/markdown-api.d.ts.map +1 -0
  104. package/dist/src/editor/signals.d.ts +242 -0
  105. package/dist/src/editor/signals.d.ts.map +1 -0
  106. package/dist/src/editor/storage.d.ts +27 -0
  107. package/dist/src/editor/storage.d.ts.map +1 -0
  108. package/dist/src/editor/text-styling.d.ts +350 -0
  109. package/dist/src/editor/text-styling.d.ts.map +1 -0
  110. package/dist/src/editor/themes.d.ts +38 -0
  111. package/dist/src/editor/themes.d.ts.map +1 -0
  112. package/dist/src/editor/types.d.ts +454 -0
  113. package/dist/src/editor/types.d.ts.map +1 -0
  114. package/dist/src/error-collector.d.ts +56 -0
  115. package/dist/src/error-collector.d.ts.map +1 -0
  116. package/dist/src/handlers/component-ops.d.ts +34 -0
  117. package/dist/src/handlers/component-ops.d.ts.map +1 -0
  118. package/dist/src/handlers/markdown-ops.d.ts +41 -0
  119. package/dist/src/handlers/markdown-ops.d.ts.map +1 -0
  120. package/dist/src/handlers/request-utils.d.ts +20 -0
  121. package/dist/src/handlers/request-utils.d.ts.map +1 -0
  122. package/dist/src/handlers/source-writer.d.ts +51 -0
  123. package/dist/src/handlers/source-writer.d.ts.map +1 -0
  124. package/dist/src/html-processor.d.ts +63 -0
  125. package/dist/src/html-processor.d.ts.map +1 -0
  126. package/dist/src/index.d.ts +41 -0
  127. package/dist/src/index.d.ts.map +1 -0
  128. package/dist/src/manifest-writer.d.ts +111 -0
  129. package/dist/src/manifest-writer.d.ts.map +1 -0
  130. package/dist/src/media/contember.d.ts +15 -0
  131. package/dist/src/media/contember.d.ts.map +1 -0
  132. package/dist/src/media/local.d.ts +9 -0
  133. package/dist/src/media/local.d.ts.map +1 -0
  134. package/dist/src/media/s3.d.ts +12 -0
  135. package/dist/src/media/s3.d.ts.map +1 -0
  136. package/dist/src/media/types.d.ts +40 -0
  137. package/dist/src/media/types.d.ts.map +1 -0
  138. package/dist/src/preview-generator.d.ts +19 -0
  139. package/dist/src/preview-generator.d.ts.map +1 -0
  140. package/dist/src/seo-processor.d.ts +23 -0
  141. package/dist/src/seo-processor.d.ts.map +1 -0
  142. package/dist/src/source-finder/ast-extractors.d.ts +35 -0
  143. package/dist/src/source-finder/ast-extractors.d.ts.map +1 -0
  144. package/dist/src/source-finder/ast-parser.d.ts +16 -0
  145. package/dist/src/source-finder/ast-parser.d.ts.map +1 -0
  146. package/dist/src/source-finder/cache.d.ts +18 -0
  147. package/dist/src/source-finder/cache.d.ts.map +1 -0
  148. package/dist/src/source-finder/collection-finder.d.ts +29 -0
  149. package/dist/src/source-finder/collection-finder.d.ts.map +1 -0
  150. package/dist/src/source-finder/cross-file-tracker.d.ts +39 -0
  151. package/dist/src/source-finder/cross-file-tracker.d.ts.map +1 -0
  152. package/dist/src/source-finder/element-finder.d.ts +42 -0
  153. package/dist/src/source-finder/element-finder.d.ts.map +1 -0
  154. package/dist/src/source-finder/image-finder.d.ts +24 -0
  155. package/dist/src/source-finder/image-finder.d.ts.map +1 -0
  156. package/dist/src/source-finder/index.d.ts +9 -0
  157. package/dist/src/source-finder/index.d.ts.map +1 -0
  158. package/dist/src/source-finder/search-index.d.ts +27 -0
  159. package/dist/src/source-finder/search-index.d.ts.map +1 -0
  160. package/dist/src/source-finder/snippet-utils.d.ts +90 -0
  161. package/dist/src/source-finder/snippet-utils.d.ts.map +1 -0
  162. package/dist/src/source-finder/source-lookup.d.ts +16 -0
  163. package/dist/src/source-finder/source-lookup.d.ts.map +1 -0
  164. package/dist/src/source-finder/types.d.ts +167 -0
  165. package/dist/src/source-finder/types.d.ts.map +1 -0
  166. package/dist/src/source-finder/variable-extraction.d.ts +37 -0
  167. package/dist/src/source-finder/variable-extraction.d.ts.map +1 -0
  168. package/dist/src/tailwind-colors.d.ts +54 -0
  169. package/dist/src/tailwind-colors.d.ts.map +1 -0
  170. package/dist/src/tsconfig.tsbuildinfo +1 -0
  171. package/dist/src/types.d.ts +367 -0
  172. package/dist/src/types.d.ts.map +1 -0
  173. package/dist/src/utils.d.ts +61 -0
  174. package/dist/src/utils.d.ts.map +1 -0
  175. package/dist/src/vite-plugin.d.ts +14 -0
  176. package/dist/src/vite-plugin.d.ts.map +1 -0
  177. package/dist/types/tsconfig.tsbuildinfo +1 -0
  178. package/package.json +80 -0
  179. package/src/build-processor.ts +784 -0
  180. package/src/collection-scanner.ts +304 -0
  181. package/src/component-registry.ts +393 -0
  182. package/src/config.ts +74 -0
  183. package/src/dev-middleware.ts +525 -0
  184. package/src/dist/src/tsconfig.tsbuildinfo +1 -0
  185. package/src/editor/ai.ts +185 -0
  186. package/src/editor/api.ts +513 -0
  187. package/src/editor/color-utils.ts +556 -0
  188. package/src/editor/components/ai-chat.tsx +632 -0
  189. package/src/editor/components/ai-tooltip.tsx +179 -0
  190. package/src/editor/components/attribute-editor.tsx +596 -0
  191. package/src/editor/components/block-editor.tsx +546 -0
  192. package/src/editor/components/collections-browser.tsx +248 -0
  193. package/src/editor/components/color-toolbar.tsx +314 -0
  194. package/src/editor/components/confirm-dialog.tsx +69 -0
  195. package/src/editor/components/create-page-modal.tsx +163 -0
  196. package/src/editor/components/editable-highlights.tsx +260 -0
  197. package/src/editor/components/error-boundary.tsx +87 -0
  198. package/src/editor/components/fields.tsx +387 -0
  199. package/src/editor/components/frontmatter-fields.tsx +469 -0
  200. package/src/editor/components/highlight-overlay.ts +229 -0
  201. package/src/editor/components/image-overlay.tsx +230 -0
  202. package/src/editor/components/markdown-editor-overlay.tsx +505 -0
  203. package/src/editor/components/markdown-inline-editor.tsx +780 -0
  204. package/src/editor/components/media-library.tsx +297 -0
  205. package/src/editor/components/outline.tsx +402 -0
  206. package/src/editor/components/redirect-countdown.tsx +45 -0
  207. package/src/editor/components/seo-editor.tsx +498 -0
  208. package/src/editor/components/text-style-toolbar.tsx +362 -0
  209. package/src/editor/components/toast/toast-container.tsx +15 -0
  210. package/src/editor/components/toast/toast.tsx +49 -0
  211. package/src/editor/components/toast/types.ts +7 -0
  212. package/src/editor/components/toolbar.tsx +366 -0
  213. package/src/editor/config.ts +12 -0
  214. package/src/editor/constants.ts +106 -0
  215. package/src/editor/context.tsx +38 -0
  216. package/src/editor/dom.ts +357 -0
  217. package/src/editor/editor.ts +1510 -0
  218. package/src/editor/env.d.ts +4 -0
  219. package/src/editor/history.ts +355 -0
  220. package/src/editor/hooks/index.ts +19 -0
  221. package/src/editor/hooks/useAIHandlers.ts +345 -0
  222. package/src/editor/hooks/useBlockEditorHandlers.ts +206 -0
  223. package/src/editor/hooks/useElementDetection.ts +284 -0
  224. package/src/editor/hooks/useImageHoverDetection.ts +102 -0
  225. package/src/editor/hooks/useTextSelection.ts +187 -0
  226. package/src/editor/hooks/useTooltipState.ts +126 -0
  227. package/src/editor/hooks/utils.ts +101 -0
  228. package/src/editor/index.tsx +481 -0
  229. package/src/editor/lib/cn.ts +4 -0
  230. package/src/editor/manifest.ts +25 -0
  231. package/src/editor/markdown-api.ts +209 -0
  232. package/src/editor/signals.ts +1351 -0
  233. package/src/editor/storage.ts +266 -0
  234. package/src/editor/styles.css +465 -0
  235. package/src/editor/text-styling.ts +773 -0
  236. package/src/editor/themes.ts +210 -0
  237. package/src/editor/types.ts +591 -0
  238. package/src/error-collector.ts +106 -0
  239. package/src/handlers/component-ops.ts +463 -0
  240. package/src/handlers/markdown-ops.ts +202 -0
  241. package/src/handlers/request-utils.ts +151 -0
  242. package/src/handlers/source-writer.ts +649 -0
  243. package/src/html-processor.ts +1108 -0
  244. package/src/index.ts +284 -0
  245. package/src/manifest-writer.ts +371 -0
  246. package/src/media/contember.ts +84 -0
  247. package/src/media/local.ts +114 -0
  248. package/src/media/s3.ts +133 -0
  249. package/src/media/types.ts +33 -0
  250. package/src/preview-generator.ts +293 -0
  251. package/src/seo-processor.ts +567 -0
  252. package/src/source-finder/ast-extractors.ts +185 -0
  253. package/src/source-finder/ast-parser.ts +150 -0
  254. package/src/source-finder/cache.ts +76 -0
  255. package/src/source-finder/collection-finder.ts +335 -0
  256. package/src/source-finder/cross-file-tracker.ts +741 -0
  257. package/src/source-finder/element-finder.ts +387 -0
  258. package/src/source-finder/image-finder.ts +283 -0
  259. package/src/source-finder/index.ts +37 -0
  260. package/src/source-finder/search-index.ts +525 -0
  261. package/src/source-finder/snippet-utils.ts +668 -0
  262. package/src/source-finder/source-lookup.ts +200 -0
  263. package/src/source-finder/types.ts +210 -0
  264. package/src/source-finder/variable-extraction.ts +406 -0
  265. package/src/tailwind-colors.ts +874 -0
  266. package/src/tsconfig.json +25 -0
  267. package/src/types.ts +406 -0
  268. package/src/utils.ts +186 -0
  269. package/src/vite-plugin.ts +42 -0
@@ -0,0 +1,84 @@
1
+ import type { MediaListResult, MediaStorageAdapter, MediaUploadResult } from './types'
2
+
3
+ export interface ContemberStorageOptions {
4
+ /** Base URL of the worker API, e.g. 'https://api.example.com' */
5
+ apiBaseUrl: string
6
+ /** Project slug used in the API path */
7
+ projectSlug: string
8
+ /** Session token for authentication (NUA_SITE_SESSION_TOKEN cookie value) */
9
+ sessionToken?: string
10
+ }
11
+
12
+ /**
13
+ * Media storage adapter that proxies to the Contember worker's CMS media endpoints.
14
+ * Uses the existing /cms/:projectSlug/media/* API backed by R2 storage + Contember database.
15
+ */
16
+ export function createContemberStorageAdapter(options: ContemberStorageOptions): MediaStorageAdapter {
17
+ const { apiBaseUrl, projectSlug, sessionToken } = options
18
+ const base = `${apiBaseUrl.replace(/\/$/, '')}/cms/${projectSlug}/media`
19
+
20
+ function headers(): Record<string, string> {
21
+ const h: Record<string, string> = {}
22
+ if (sessionToken) {
23
+ h.Cookie = `NUA_SITE_SESSION_TOKEN=${sessionToken}`
24
+ }
25
+ return h
26
+ }
27
+
28
+ return {
29
+ async list(opts) {
30
+ const params = new URLSearchParams()
31
+ if (opts?.limit) params.set('limit', String(opts.limit))
32
+ if (opts?.cursor) params.set('cursor', opts.cursor)
33
+
34
+ const url = `${base}/list${params.toString() ? `?${params}` : ''}`
35
+ const res = await fetch(url, {
36
+ method: 'GET',
37
+ headers: headers(),
38
+ credentials: 'include',
39
+ })
40
+
41
+ if (!res.ok) {
42
+ throw new Error(`Failed to list media (${res.status}): ${await res.text()}`)
43
+ }
44
+
45
+ return (await res.json()) as MediaListResult
46
+ },
47
+
48
+ async upload(file, filename, contentType) {
49
+ // Build a FormData with the file buffer
50
+ const blob = new Blob([new Uint8Array(file)], { type: contentType })
51
+ const formData = new FormData()
52
+ formData.append('file', blob, filename)
53
+
54
+ const res = await fetch(`${base}/upload`, {
55
+ method: 'POST',
56
+ headers: headers(),
57
+ body: formData,
58
+ credentials: 'include',
59
+ })
60
+
61
+ if (!res.ok) {
62
+ const text = await res.text().catch(() => '')
63
+ return { success: false, error: `Upload failed (${res.status}): ${text}` }
64
+ }
65
+
66
+ return (await res.json()) as MediaUploadResult
67
+ },
68
+
69
+ async delete(id) {
70
+ const res = await fetch(`${base}/${encodeURIComponent(id)}`, {
71
+ method: 'DELETE',
72
+ headers: headers(),
73
+ credentials: 'include',
74
+ })
75
+
76
+ if (!res.ok) {
77
+ const text = await res.text().catch(() => '')
78
+ return { success: false, error: `Delete failed (${res.status}): ${text}` }
79
+ }
80
+
81
+ return { success: true }
82
+ },
83
+ }
84
+ }
@@ -0,0 +1,114 @@
1
+ import { randomUUID } from 'node:crypto'
2
+ import fs from 'node:fs/promises'
3
+ import path from 'node:path'
4
+ import type { MediaListResult, MediaStorageAdapter, MediaUploadResult } from './types'
5
+
6
+ export interface LocalStorageOptions {
7
+ /** Directory to store media files (relative to project root or absolute). Default: 'public/uploads' */
8
+ dir?: string
9
+ /** URL prefix for serving files. Default: '/uploads' */
10
+ urlPrefix?: string
11
+ }
12
+
13
+ export function createLocalStorageAdapter(options: LocalStorageOptions = {}): MediaStorageAdapter {
14
+ const dir = path.resolve(options.dir ?? 'public/uploads')
15
+ const urlPrefix = (options.urlPrefix ?? '/uploads').replace(/\/$/, '')
16
+
17
+ return {
18
+ staticFiles: { urlPrefix, dir },
19
+
20
+ async list(opts) {
21
+ const limit = opts?.limit ?? 50
22
+ const offset = opts?.cursor ? parseInt(opts.cursor, 10) : 0
23
+
24
+ await fs.mkdir(dir, { recursive: true })
25
+
26
+ const entries = await fs.readdir(dir, { withFileTypes: true })
27
+ const files = entries.filter((e) => e.isFile() && !e.name.startsWith('.'))
28
+
29
+ // Get stats for sorting by mtime desc
30
+ const withStats = await Promise.all(
31
+ files.map(async (f) => {
32
+ const filePath = path.join(dir, f.name)
33
+ const stat = await fs.stat(filePath)
34
+ return { name: f.name, stat }
35
+ }),
36
+ )
37
+ withStats.sort((a, b) => b.stat.mtimeMs - a.stat.mtimeMs)
38
+
39
+ const slice = withStats.slice(offset, offset + limit)
40
+ const hasMore = offset + limit < withStats.length
41
+
42
+ const items = slice.map((f) => {
43
+ const ext = path.extname(f.name).toLowerCase()
44
+ const contentType = mimeFromExt(ext)
45
+ return {
46
+ id: f.name,
47
+ url: `${urlPrefix}/${f.name}`,
48
+ filename: f.name,
49
+ contentType,
50
+ uploadedAt: f.stat.mtime.toISOString(),
51
+ }
52
+ })
53
+
54
+ return {
55
+ items,
56
+ hasMore,
57
+ cursor: hasMore ? String(offset + limit) : undefined,
58
+ } satisfies MediaListResult
59
+ },
60
+
61
+ async upload(file, filename, contentType) {
62
+ await fs.mkdir(dir, { recursive: true })
63
+
64
+ const ext = getFileExtension(filename)
65
+ const uuid = randomUUID()
66
+ const newFilename = `${uuid}${ext ? `.${ext}` : ''}`
67
+ const filePath = path.join(dir, newFilename)
68
+
69
+ await fs.writeFile(filePath, file)
70
+
71
+ return {
72
+ success: true,
73
+ url: `${urlPrefix}/${newFilename}`,
74
+ filename: newFilename,
75
+ id: newFilename,
76
+ } satisfies MediaUploadResult
77
+ },
78
+
79
+ async delete(id) {
80
+ const filePath = path.join(dir, path.basename(id))
81
+ try {
82
+ await fs.unlink(filePath)
83
+ return { success: true }
84
+ } catch (error) {
85
+ const message = error instanceof Error ? error.message : String(error)
86
+ return { success: false, error: message }
87
+ }
88
+ },
89
+ }
90
+ }
91
+
92
+ function getFileExtension(filename: string): string {
93
+ const parts = filename.split('.')
94
+ const ext = parts.length > 1 ? (parts.pop()?.toLowerCase() ?? '') : ''
95
+ // Only allow alphanumeric extensions to prevent injection
96
+ return /^[a-z0-9]+$/.test(ext) ? ext : ''
97
+ }
98
+
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'
114
+ }
@@ -0,0 +1,133 @@
1
+ import { randomUUID } from 'node:crypto'
2
+ import path from 'node:path'
3
+ import type { MediaListResult, MediaStorageAdapter, MediaUploadResult } from './types'
4
+
5
+ export interface S3StorageOptions {
6
+ bucket: string
7
+ region: string
8
+ accessKeyId?: string
9
+ secretAccessKey?: string
10
+ endpoint?: string
11
+ cdnPrefix?: string
12
+ prefix?: string
13
+ }
14
+
15
+ // Dynamic import helper to avoid TS2307 for optional peer dependency
16
+ const s3Module = '@aws-sdk/client-s3'
17
+ async function loadS3(): Promise<any> {
18
+ return import(/* @vite-ignore */ s3Module)
19
+ }
20
+
21
+ export function createS3StorageAdapter(options: S3StorageOptions): MediaStorageAdapter {
22
+ const { bucket, region, accessKeyId, secretAccessKey, endpoint, cdnPrefix, prefix = 'uploads' } = options
23
+
24
+ let s3Client: any = null
25
+
26
+ async function getClient() {
27
+ if (s3Client) return s3Client
28
+ const { S3Client } = await loadS3()
29
+ s3Client = new S3Client({
30
+ region,
31
+ ...(endpoint ? { endpoint } : {}),
32
+ ...(accessKeyId && secretAccessKey
33
+ ? { credentials: { accessKeyId, secretAccessKey } }
34
+ : {}),
35
+ })
36
+ return s3Client
37
+ }
38
+
39
+ function getUrl(key: string): string {
40
+ if (cdnPrefix) {
41
+ return `${cdnPrefix.replace(/\/$/, '')}/${key}`
42
+ }
43
+ if (endpoint) {
44
+ return `${endpoint.replace(/\/$/, '')}/${bucket}/${key}`
45
+ }
46
+ return `https://${bucket}.s3.${region}.amazonaws.com/${key}`
47
+ }
48
+
49
+ return {
50
+ async list(opts) {
51
+ const { ListObjectsV2Command } = await loadS3()
52
+ const client = await getClient()
53
+
54
+ const limit = opts?.limit ?? 50
55
+ const command = new ListObjectsV2Command({
56
+ Bucket: bucket,
57
+ Prefix: prefix,
58
+ MaxKeys: limit + 1,
59
+ ...(opts?.cursor ? { ContinuationToken: opts.cursor } : {}),
60
+ })
61
+
62
+ const result = await client.send(command)
63
+ const contents = result.Contents ?? []
64
+ const hasMore = contents.length > limit
65
+ const items = contents.slice(0, limit).map((obj: any) => {
66
+ const key = obj.Key as string
67
+ const filename = key.split('/').pop() ?? key
68
+ return {
69
+ id: key,
70
+ url: getUrl(key),
71
+ filename,
72
+ contentType: 'application/octet-stream',
73
+ uploadedAt: obj.LastModified?.toISOString(),
74
+ }
75
+ })
76
+
77
+ return {
78
+ items,
79
+ hasMore,
80
+ cursor: hasMore ? result.NextContinuationToken : undefined,
81
+ } satisfies MediaListResult
82
+ },
83
+
84
+ async upload(file, filename, contentType) {
85
+ const { PutObjectCommand } = await loadS3()
86
+ const client = await getClient()
87
+
88
+ const ext = getFileExtension(filename)
89
+ const uuid = randomUUID()
90
+ const newFilename = `${uuid}${ext ? `.${ext}` : ''}`
91
+ const key = prefix ? `${prefix}/${newFilename}` : newFilename
92
+
93
+ const command = new PutObjectCommand({
94
+ Bucket: bucket,
95
+ Key: key,
96
+ Body: file,
97
+ ContentType: contentType,
98
+ })
99
+
100
+ await client.send(command)
101
+
102
+ return {
103
+ success: true,
104
+ url: getUrl(key),
105
+ filename: newFilename,
106
+ id: key,
107
+ } satisfies MediaUploadResult
108
+ },
109
+
110
+ async delete(id) {
111
+ try {
112
+ const { DeleteObjectCommand } = await loadS3()
113
+ const client = await getClient()
114
+
115
+ const command = new DeleteObjectCommand({
116
+ Bucket: bucket,
117
+ Key: id,
118
+ })
119
+
120
+ await client.send(command)
121
+ return { success: true }
122
+ } catch (error) {
123
+ const message = error instanceof Error ? error.message : String(error)
124
+ return { success: false, error: message }
125
+ }
126
+ },
127
+ }
128
+ }
129
+
130
+ function getFileExtension(filename: string): string {
131
+ const parts = filename.split('.')
132
+ return parts.length > 1 ? (parts.pop()?.toLowerCase() ?? '') : ''
133
+ }
@@ -0,0 +1,33 @@
1
+ export interface MediaItem {
2
+ id: string
3
+ url: string
4
+ filename: string
5
+ annotation?: string
6
+ contentType: string
7
+ width?: number
8
+ height?: number
9
+ uploadedAt?: string
10
+ }
11
+
12
+ export interface MediaListResult {
13
+ items: MediaItem[]
14
+ hasMore: boolean
15
+ cursor?: string
16
+ }
17
+
18
+ export interface MediaUploadResult {
19
+ success: boolean
20
+ url?: string
21
+ filename?: string
22
+ annotation?: string
23
+ id?: string
24
+ error?: string
25
+ }
26
+
27
+ export interface MediaStorageAdapter {
28
+ list(options?: { limit?: number; cursor?: string }): Promise<MediaListResult>
29
+ upload(file: Buffer, filename: string, contentType: string): Promise<MediaUploadResult>
30
+ delete(id: string): Promise<{ success: boolean; error?: string }>
31
+ /** Local filesystem info for direct file serving in dev (bypasses Vite's public dir cache) */
32
+ staticFiles?: { urlPrefix: string; dir: string }
33
+ }
@@ -0,0 +1,293 @@
1
+ import { parse } from 'node-html-parser'
2
+ import fs from 'node:fs/promises'
3
+ import path from 'node:path'
4
+ import type { ComponentDefinition, ComponentInstance, ManifestEntry } from './types'
5
+ import type { CollectionEntry, PageSeoData } from './types'
6
+
7
+ type PageData = {
8
+ entries: Record<string, ManifestEntry>
9
+ components: Record<string, ComponentInstance>
10
+ collection?: CollectionEntry
11
+ seo?: PageSeoData
12
+ }
13
+
14
+ /**
15
+ * Check if an image element's src or srcset contains the given URL.
16
+ * Handles CDN-transformed URLs by comparing path suffixes.
17
+ */
18
+ function imageMatchesSrc(el: ReturnType<typeof parse>, srcValue: string): boolean {
19
+ // Check src attribute
20
+ const src = el.getAttribute('src')
21
+ if (src === srcValue) return true
22
+
23
+ // Check if src or srcset URLs contain the original path
24
+ // (handles CDN transformations like /cdn-cgi/image/.../original-path)
25
+ let srcPath: string
26
+ try {
27
+ srcPath = new URL(srcValue).pathname
28
+ } catch {
29
+ srcPath = srcValue.split('?')[0] ?? srcValue
30
+ }
31
+ if (srcPath.length <= 5) return false
32
+
33
+ if (src) {
34
+ let elPath: string
35
+ try {
36
+ elPath = new URL(src).pathname
37
+ } catch {
38
+ elPath = src.split('?')[0] ?? src
39
+ }
40
+ if (elPath.endsWith(srcPath) || srcPath.endsWith(elPath)) return true
41
+ }
42
+
43
+ // Check srcset URLs
44
+ const srcset = el.getAttribute('srcset')
45
+ if (srcset) {
46
+ const urls = srcset.split(',').map(entry => entry.trim().split(/\s+/)[0]).filter(Boolean)
47
+ for (const url of urls) {
48
+ if (!url) continue
49
+ let urlPath: string
50
+ try {
51
+ urlPath = new URL(url).pathname
52
+ } catch {
53
+ urlPath = url.split('?')[0] ?? url
54
+ }
55
+ if (urlPath.endsWith(srcPath) || srcPath.endsWith(urlPath)) return true
56
+ }
57
+ }
58
+
59
+ return false
60
+ }
61
+
62
+ /**
63
+ * Annotate elements in the component HTML with `data-cms-preview-prop` attributes.
64
+ * For each string prop, find the first leaf element whose trimmed text content
65
+ * matches the prop's rendered value, and tag it.
66
+ * Also annotates <img> elements whose src/srcset matches image prop values.
67
+ */
68
+ function annotatePreviewProps(
69
+ componentHtml: ReturnType<typeof parse>,
70
+ props: Record<string, any>,
71
+ propDefs: ComponentDefinition['props'],
72
+ ): void {
73
+ const annotated = new Set<string>()
74
+
75
+ for (const def of propDefs) {
76
+ // Only annotate string-type props
77
+ if (def.type !== 'string') continue
78
+ const value = props[def.name]
79
+ if (typeof value !== 'string' || !value.trim()) continue
80
+
81
+ const trimmedValue = value.trim()
82
+
83
+ // First, check <img> elements for image props (src/srcset matching)
84
+ if (!annotated.has(def.name)) {
85
+ const imgElements = componentHtml.querySelectorAll('img')
86
+ for (const img of imgElements) {
87
+ if (img.getAttribute('data-cms-preview-prop')) continue
88
+ if (imageMatchesSrc(img, trimmedValue)) {
89
+ img.setAttribute('data-cms-preview-prop', def.name)
90
+ img.setAttribute('data-cms-preview-type', 'image')
91
+ annotated.add(def.name)
92
+ break
93
+ }
94
+ }
95
+ }
96
+
97
+ if (annotated.has(def.name)) continue
98
+
99
+ // Find leaf text nodes whose content matches
100
+ const allElements = componentHtml.querySelectorAll('*')
101
+ for (const el of allElements) {
102
+ // Skip elements that already have an annotation
103
+ if (el.getAttribute('data-cms-preview-prop')) continue
104
+
105
+ // Check if this is a leaf element (no child elements, only text)
106
+ if (el.childNodes.length === 0) continue
107
+ const hasChildElements = el.childNodes.some(
108
+ (n) => n.nodeType === 1, // ELEMENT_NODE
109
+ )
110
+
111
+ // For leaf elements or elements with only text children
112
+ if (!hasChildElements) {
113
+ const textContent = el.textContent.trim()
114
+ if (textContent === trimmedValue && !annotated.has(def.name)) {
115
+ el.setAttribute('data-cms-preview-prop', def.name)
116
+ annotated.add(def.name)
117
+ break
118
+ }
119
+ }
120
+ }
121
+ }
122
+ }
123
+
124
+ /**
125
+ * Generate a standalone preview HTML page for a component.
126
+ */
127
+ function generatePreviewHtml(
128
+ componentOuterHtml: string,
129
+ headStyles: string,
130
+ ): string {
131
+ return `<!DOCTYPE html>
132
+ <html>
133
+ <head>
134
+ <meta charset="utf-8">
135
+ <meta name="robots" content="noindex, nofollow">
136
+ <meta name="viewport" content="width=device-width, initial-scale=1">
137
+ ${headStyles}
138
+ <style>
139
+ body { margin: 0; padding: 0; }
140
+ .cms-preview-container { overflow: hidden; }
141
+ </style>
142
+ </head>
143
+ <body>
144
+ <div class="cms-preview-container">${componentOuterHtml}</div>
145
+ <script>
146
+ // Notify parent that preview is ready
147
+ if (window.parent !== window) {
148
+ window.parent.postMessage({ type: 'cms-preview-ready' }, window.location.origin);
149
+ }
150
+
151
+ // Listen for prop updates from the CMS editor
152
+ window.addEventListener('message', function(event) {
153
+ // Only accept messages from same origin for security
154
+ if (event.origin !== window.location.origin) return;
155
+ if (!event.data || event.data.type !== 'cms-preview-update') return;
156
+ var props = event.data.props;
157
+ if (!props) return;
158
+
159
+ var elements = document.querySelectorAll('[data-cms-preview-prop]');
160
+ for (var i = 0; i < elements.length; i++) {
161
+ var el = elements[i];
162
+ var propName = el.getAttribute('data-cms-preview-prop');
163
+ if (propName && props[propName] !== undefined) {
164
+ if (el.getAttribute('data-cms-preview-type') === 'image') {
165
+ el.setAttribute('src', String(props[propName]));
166
+ el.removeAttribute('srcset');
167
+ } else {
168
+ el.textContent = String(props[propName]);
169
+ }
170
+ }
171
+ }
172
+ });
173
+ </script>
174
+ </body>
175
+ </html>`
176
+ }
177
+
178
+ /**
179
+ * Extract <link rel="stylesheet"> and <style> tags from a page's <head>.
180
+ */
181
+ function extractHeadStyles(root: ReturnType<typeof parse>): string {
182
+ const head = root.querySelector('head')
183
+ if (!head) return ''
184
+
185
+ const parts: string[] = []
186
+
187
+ // Extract <link rel="stylesheet"> tags
188
+ for (const link of head.querySelectorAll('link[rel="stylesheet"]')) {
189
+ parts.push(link.outerHTML)
190
+ }
191
+
192
+ // Extract <style> tags
193
+ for (const style of head.querySelectorAll('style')) {
194
+ parts.push(style.outerHTML)
195
+ }
196
+
197
+ return parts.join('\n')
198
+ }
199
+
200
+ /**
201
+ * Generate standalone preview HTML files for each component that has
202
+ * at least one instance on a built page.
203
+ *
204
+ * Reads the built HTML, extracts the component DOM fragment, annotates
205
+ * text props for live preview updates, and writes a self-contained HTML
206
+ * page to `outDir/_cms-preview/<ComponentName>/index.html`.
207
+ */
208
+ export async function generateComponentPreviews(
209
+ outDir: string,
210
+ pageManifests: Map<string, PageData>,
211
+ componentDefinitions: Record<string, ComponentDefinition>,
212
+ ): Promise<void> {
213
+ // Track which component names we've already processed
214
+ const processed = new Set<string>()
215
+
216
+ // Build a list of work: for each page, find components we haven't processed yet
217
+ for (const [pagePath, pageData] of pageManifests) {
218
+ const componentsToProcess: Array<{
219
+ componentName: string
220
+ instance: ComponentInstance
221
+ }> = []
222
+
223
+ for (const instance of Object.values(pageData.components)) {
224
+ if (processed.has(instance.componentName)) continue
225
+ if (!componentDefinitions[instance.componentName]) continue
226
+ processed.add(instance.componentName)
227
+ componentsToProcess.push({ componentName: instance.componentName, instance })
228
+ }
229
+
230
+ if (componentsToProcess.length === 0) continue
231
+
232
+ // Resolve the HTML file path for this page
233
+ let htmlFilePath: string
234
+ if (pagePath === '/' || pagePath === '') {
235
+ htmlFilePath = path.join(outDir, 'index.html')
236
+ } else {
237
+ const cleanPath = pagePath.replace(/^\//, '')
238
+ // Try directory-style first (e.g., about/index.html)
239
+ const dirStyle = path.join(outDir, cleanPath, 'index.html')
240
+ const fileStyle = path.join(outDir, `${cleanPath}.html`)
241
+ try {
242
+ await fs.access(dirStyle)
243
+ htmlFilePath = dirStyle
244
+ } catch {
245
+ htmlFilePath = fileStyle
246
+ }
247
+ }
248
+
249
+ let pageHtml: string
250
+ try {
251
+ pageHtml = await fs.readFile(htmlFilePath, 'utf-8')
252
+ } catch {
253
+ // Page HTML not found, skip
254
+ continue
255
+ }
256
+
257
+ const root = parse(pageHtml, { lowerCaseTagName: false, comment: true })
258
+ const headStyles = extractHeadStyles(root)
259
+
260
+ for (const { componentName, instance } of componentsToProcess) {
261
+ const def = componentDefinitions[componentName]
262
+ if (!def) continue
263
+
264
+ // Find the component element in the DOM
265
+ const componentEl = root.querySelector(
266
+ `[data-cms-component-id="${instance.id}"]`,
267
+ )
268
+ if (!componentEl) continue
269
+
270
+ // Clone the component HTML for annotation
271
+ const componentFragment = parse(componentEl.outerHTML, {
272
+ lowerCaseTagName: false,
273
+ comment: true,
274
+ })
275
+
276
+ // Annotate text props for live preview
277
+ annotatePreviewProps(componentFragment, instance.props, def.props)
278
+
279
+ const previewHtml = generatePreviewHtml(
280
+ componentFragment.toString(),
281
+ headStyles,
282
+ )
283
+
284
+ // Write preview file
285
+ const previewDir = path.join(outDir, '_cms-preview', componentName)
286
+ await fs.mkdir(previewDir, { recursive: true })
287
+ await fs.writeFile(path.join(previewDir, 'index.html'), previewHtml, 'utf-8')
288
+
289
+ // Set the preview URL on the component definition
290
+ def.previewUrl = `/_cms-preview/${componentName}/`
291
+ }
292
+ }
293
+ }