@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
package/src/config.ts ADDED
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Global configuration for cms-marker.
3
+ * This allows overriding the project root for testing.
4
+ */
5
+
6
+ import { existsSync, readFileSync } from 'node:fs'
7
+ import path from 'node:path'
8
+
9
+ let projectRootOverride: string | null = null
10
+
11
+ /**
12
+ * Get the current project root directory.
13
+ * Returns the override if set, otherwise process.cwd().
14
+ */
15
+ export function getProjectRoot(): string {
16
+ return projectRootOverride ?? process.cwd()
17
+ }
18
+
19
+ /**
20
+ * Set the project root directory override.
21
+ * Call this to use a specific directory instead of process.cwd().
22
+ */
23
+ export function setProjectRoot(root: string): void {
24
+ projectRootOverride = root
25
+ }
26
+
27
+ /**
28
+ * Reset the project root to use process.cwd() again.
29
+ */
30
+ export function resetProjectRoot(): void {
31
+ projectRootOverride = null
32
+ }
33
+
34
+ /**
35
+ * Get the validation root for path traversal checks.
36
+ * In a monorepo/workspace, this returns the workspace root so that
37
+ * files in sibling packages are considered valid.
38
+ * Falls back to getProjectRoot() when not in a workspace.
39
+ */
40
+ let validationRootCache: string | undefined
41
+ export function getValidationRoot(): string {
42
+ if (validationRootCache !== undefined) return validationRootCache
43
+ const wsRoot = findWorkspaceRoot(getProjectRoot())
44
+ validationRootCache = wsRoot ?? getProjectRoot()
45
+ return validationRootCache
46
+ }
47
+
48
+ /**
49
+ * Reset the cached validation root (call when project root changes).
50
+ */
51
+ export function resetValidationRoot(): void {
52
+ validationRootCache = undefined
53
+ }
54
+
55
+ /**
56
+ * Walk up from startDir to find the nearest package.json with a "workspaces" field.
57
+ * Returns the workspace root directory, or null if not found.
58
+ */
59
+ function findWorkspaceRoot(startDir: string): string | null {
60
+ let dir = path.resolve(startDir)
61
+ while (true) {
62
+ const pkgPath = path.join(dir, 'package.json')
63
+ if (existsSync(pkgPath)) {
64
+ try {
65
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'))
66
+ if (pkg.workspaces) return dir
67
+ } catch {}
68
+ }
69
+ const parent = path.dirname(dir)
70
+ if (parent === dir) break
71
+ dir = parent
72
+ }
73
+ return null
74
+ }
@@ -0,0 +1,525 @@
1
+ import { parse } from 'node-html-parser'
2
+ import fs from 'node:fs/promises'
3
+ import type { IncomingMessage, ServerResponse } from 'node:http'
4
+ import path from 'node:path'
5
+ import { handleInsertComponent, handleRemoveComponent } from './handlers/component-ops'
6
+ import {
7
+ handleCreateMarkdown,
8
+ handleGetMarkdownContent,
9
+ handleUpdateMarkdown,
10
+ } from './handlers/markdown-ops'
11
+ import {
12
+ handleCors,
13
+ parseJsonBody,
14
+ parseMultipartFile,
15
+ readBody,
16
+ sendError,
17
+ sendJson,
18
+ } from './handlers/request-utils'
19
+ import { handleUpdate } from './handlers/source-writer'
20
+ import { processHtml } from './html-processor'
21
+ import type { ManifestWriter } from './manifest-writer'
22
+ import type { MediaStorageAdapter } from './media/types'
23
+ import { clearSourceFinderCache, findCollectionSource, findImageSourceLocation, initializeSearchIndex, parseMarkdownContent } from './source-finder'
24
+ import type { CmsMarkerOptions, CollectionEntry, ComponentDefinition, PageSeoData } from './types'
25
+ import { normalizePagePath } from './utils'
26
+
27
+ /** Minimal ViteDevServer interface to avoid version conflicts between Astro's bundled Vite and root Vite */
28
+ interface ViteDevServerLike {
29
+ middlewares: {
30
+ use: (middleware: (req: IncomingMessage, res: ServerResponse, next: () => void) => void) => void
31
+ }
32
+ transformIndexHtml: (url: string, html: string) => Promise<string>
33
+ }
34
+
35
+ export interface DevMiddlewareOptions {
36
+ enableCmsApi?: boolean
37
+ mediaAdapter?: MediaStorageAdapter
38
+ }
39
+
40
+ export function createDevMiddleware(
41
+ server: ViteDevServerLike,
42
+ config: Required<CmsMarkerOptions>,
43
+ manifestWriter: ManifestWriter,
44
+ componentDefinitions: Record<string, ComponentDefinition>,
45
+ idCounter: { value: number },
46
+ options: DevMiddlewareOptions = {},
47
+ ) {
48
+ // Serve uploaded media files directly from disk.
49
+ // Vite's public dir middleware caches file listings, so newly uploaded files
50
+ // may not be available immediately. This middleware bypasses that cache.
51
+ if (options.mediaAdapter?.staticFiles) {
52
+ const { urlPrefix, dir } = options.mediaAdapter.staticFiles
53
+ const prefix = urlPrefix.endsWith('/') ? urlPrefix : `${urlPrefix}/`
54
+
55
+ server.middlewares.use((req, res, next) => {
56
+ const pathname = (req.url || '').split('?')[0] || ''
57
+ if (!pathname.startsWith(prefix)) {
58
+ next()
59
+ return
60
+ }
61
+
62
+ const filename = path.basename(pathname)
63
+ if (!filename || filename.includes('..')) {
64
+ next()
65
+ return
66
+ }
67
+
68
+ const filePath = path.join(dir, filename)
69
+ fs.readFile(filePath)
70
+ .then((data) => {
71
+ const ext = path.extname(filename).toLowerCase()
72
+ res.setHeader('Content-Type', mediaMimeFromExt(ext))
73
+ res.setHeader('Cache-Control', 'no-store')
74
+ res.end(data)
75
+ })
76
+ .catch(() => next())
77
+ })
78
+ }
79
+
80
+ // CMS API endpoints (local dev server backend)
81
+ if (options.enableCmsApi) {
82
+ server.middlewares.use((req, res, next) => {
83
+ const url = req.url || ''
84
+ if (!url.startsWith('/_nua/cms/')) {
85
+ next()
86
+ return
87
+ }
88
+
89
+ if (handleCors(req, res)) return
90
+
91
+ const route = url.replace('/_nua/cms/', '').split('?')[0]!
92
+
93
+ handleCmsApiRoute(route, req, res, manifestWriter, options.mediaAdapter).catch(
94
+ (error) => {
95
+ console.error('[astro-cms] API error:', error)
96
+ sendError(res, 'Internal server error', 500)
97
+ },
98
+ )
99
+ })
100
+ }
101
+
102
+ // Serve global CMS manifest (component definitions, available colors, collection definitions, and settings)
103
+ server.middlewares.use((req, res, next) => {
104
+ const pathname = (req.url || '').split('?')[0]
105
+ if (pathname === '/cms-manifest.json') {
106
+ res.setHeader('Content-Type', 'application/json')
107
+ res.setHeader('Access-Control-Allow-Origin', '*')
108
+ res.setHeader('Cache-Control', 'no-store')
109
+ const manifest: Record<string, unknown> = {
110
+ componentDefinitions,
111
+ availableColors: manifestWriter.getAvailableColors(),
112
+ availableTextStyles: manifestWriter.getAvailableTextStyles(),
113
+ }
114
+ const collectionDefs = manifestWriter.getCollectionDefinitions()
115
+ if (Object.keys(collectionDefs).length > 0) {
116
+ manifest.collectionDefinitions = collectionDefs
117
+ }
118
+ res.end(JSON.stringify(manifest, null, 2))
119
+ return
120
+ }
121
+ next()
122
+ })
123
+
124
+ // Serve per-page manifest endpoints (e.g., /about.json for /about page)
125
+ server.middlewares.use((req, res, next) => {
126
+ const url = (req.url || '').split('?')[0]!
127
+
128
+ // Match /*.json pattern (but not files that actually exist)
129
+ const match = url.match(/^\/(.*)\.json$/)
130
+ if (match) {
131
+ // Convert manifest path to page path
132
+ // e.g., /about.json -> /about
133
+ // /index.json -> /
134
+ // /blog/post.json -> /blog/post
135
+ let pagePath = '/' + match[1]
136
+ if (pagePath === '/index') {
137
+ pagePath = '/'
138
+ }
139
+
140
+ const pageData = manifestWriter.getPageManifest(pagePath)
141
+
142
+ // Only serve if we have manifest data for this page
143
+ if (pageData) {
144
+ res.setHeader('Content-Type', 'application/json')
145
+ res.setHeader('Access-Control-Allow-Origin', '*')
146
+ res.setHeader('Cache-Control', 'no-store')
147
+ const responseData: Record<string, unknown> = {
148
+ page: pagePath,
149
+ entries: pageData.entries,
150
+ components: pageData.components,
151
+ componentDefinitions,
152
+ }
153
+ if (pageData.collection) {
154
+ responseData.collection = pageData.collection
155
+ }
156
+ if (pageData.seo) {
157
+ responseData.seo = pageData.seo
158
+ }
159
+ res.end(JSON.stringify(responseData, null, 2))
160
+ return
161
+ }
162
+ }
163
+ next()
164
+ })
165
+
166
+ // Transform HTML responses — only buffer when Content-Type is text/html
167
+ server.middlewares.use((req, res, next) => {
168
+ const originalWrite = res.write
169
+ const originalEnd = res.end
170
+ const requestUrl = req.url || 'unknown'
171
+ let chunks: Buffer[] | null = null
172
+ let isHtml: boolean | null = null
173
+
174
+ const checkIfHtml = (): boolean => {
175
+ if (isHtml !== null) return isHtml
176
+ const contentType = res.getHeader('content-type')
177
+ isHtml = !!(contentType && typeof contentType === 'string' && contentType.includes('text/html'))
178
+ if (isHtml) {
179
+ chunks = []
180
+ }
181
+ return isHtml
182
+ }
183
+
184
+ // Intercept response chunks — only buffer for HTML
185
+ res.write = ((chunk: any, encodingOrCb?: any, cb?: any) => {
186
+ if (!checkIfHtml()) {
187
+ // Not HTML — pass through immediately, preserving backpressure
188
+ return originalWrite.call(res, chunk, encodingOrCb, cb)
189
+ }
190
+ if (chunk) {
191
+ chunks!.push(typeof chunk === 'string' ? Buffer.from(chunk, typeof encodingOrCb === 'string' ? encodingOrCb as BufferEncoding : 'utf-8') : Buffer.from(chunk))
192
+ }
193
+ if (typeof encodingOrCb === 'function') encodingOrCb()
194
+ else if (typeof cb === 'function') cb()
195
+ return true
196
+ }) as any
197
+
198
+ res.end = ((chunk: any, ...args: any[]) => {
199
+ if (!checkIfHtml()) {
200
+ // Not HTML — pass through
201
+ res.write = originalWrite
202
+ res.end = originalEnd
203
+ return res.end(chunk, ...args)
204
+ }
205
+
206
+ if (chunk) {
207
+ chunks!.push(Buffer.from(chunk))
208
+ }
209
+
210
+ const html = Buffer.concat(chunks!).toString('utf8')
211
+ const pagePath = normalizePagePath(requestUrl)
212
+
213
+ // Process HTML asynchronously
214
+ processHtmlForDev(html, pagePath, config, idCounter)
215
+ .then(({ html: transformed, entries, components, collection, seo }) => {
216
+ manifestWriter.addPage(pagePath, entries, components, collection, seo)
217
+
218
+ res.write = originalWrite
219
+ res.end = originalEnd
220
+ if (!res.headersSent) {
221
+ res.removeHeader('content-length')
222
+ }
223
+
224
+ return res.end(transformed, ...args)
225
+ })
226
+ .catch((error) => {
227
+ console.error('[cms] Error transforming HTML:', error)
228
+
229
+ res.write = originalWrite
230
+ res.end = originalEnd
231
+
232
+ if (chunks!.length > 0) {
233
+ return res.end(Buffer.concat(chunks!), ...args)
234
+ }
235
+ return res.end(...args)
236
+ })
237
+ return
238
+ }) as any
239
+
240
+ next()
241
+ })
242
+ }
243
+
244
+ async function handleCmsApiRoute(
245
+ route: string,
246
+ req: IncomingMessage,
247
+ res: ServerResponse,
248
+ manifestWriter: ManifestWriter,
249
+ mediaAdapter?: MediaStorageAdapter,
250
+ ): Promise<void> {
251
+ // POST /_nua/cms/update
252
+ if (route === 'update' && req.method === 'POST') {
253
+ const body = await parseJsonBody<Parameters<typeof handleUpdate>[0]>(req)
254
+ const result = await handleUpdate(body, manifestWriter)
255
+ sendJson(res, result)
256
+ return
257
+ }
258
+
259
+ // POST /_nua/cms/insert-component
260
+ if (route === 'insert-component' && req.method === 'POST') {
261
+ const body = await parseJsonBody<Parameters<typeof handleInsertComponent>[0]>(req)
262
+ const result = await handleInsertComponent(body, manifestWriter)
263
+ sendJson(res, result)
264
+ return
265
+ }
266
+
267
+ // POST /_nua/cms/remove-component
268
+ if (route === 'remove-component' && req.method === 'POST') {
269
+ const body = await parseJsonBody<Parameters<typeof handleRemoveComponent>[0]>(req)
270
+ const result = await handleRemoveComponent(body, manifestWriter)
271
+ sendJson(res, result)
272
+ return
273
+ }
274
+
275
+ // GET /_nua/cms/markdown/content?filePath=...
276
+ if (route === 'markdown/content' && req.method === 'GET') {
277
+ const urlObj = new URL(req.url!, `http://${req.headers.host}`)
278
+ const filePath = urlObj.searchParams.get('filePath')
279
+ if (!filePath) {
280
+ sendError(res, 'filePath query parameter required')
281
+ return
282
+ }
283
+ const result = await handleGetMarkdownContent(filePath)
284
+ if (!result) {
285
+ sendError(res, 'File not found', 404)
286
+ return
287
+ }
288
+ sendJson(res, result)
289
+ return
290
+ }
291
+
292
+ // POST /_nua/cms/markdown/update
293
+ if (route === 'markdown/update' && req.method === 'POST') {
294
+ const body = await parseJsonBody<Parameters<typeof handleUpdateMarkdown>[0]>(req)
295
+ const result = await handleUpdateMarkdown(body)
296
+ sendJson(res, result)
297
+ return
298
+ }
299
+
300
+ // POST /_nua/cms/markdown/create
301
+ if (route === 'markdown/create' && req.method === 'POST') {
302
+ const body = await parseJsonBody<Parameters<typeof handleCreateMarkdown>[0]>(req)
303
+ const result = await handleCreateMarkdown(body)
304
+ sendJson(res, result, result.success ? 200 : 400)
305
+ return
306
+ }
307
+
308
+ // GET /_nua/cms/media/list
309
+ if (route === 'media/list' && req.method === 'GET') {
310
+ if (!mediaAdapter) {
311
+ sendError(res, 'Media storage not configured', 501)
312
+ return
313
+ }
314
+ const urlObj = new URL(req.url!, `http://${req.headers.host}`)
315
+ const parsedLimit = parseInt(urlObj.searchParams.get('limit') ?? '50', 10)
316
+ const limit = Number.isNaN(parsedLimit) || parsedLimit < 1 ? 50 : Math.min(parsedLimit, 1000)
317
+ const cursor = urlObj.searchParams.get('cursor') ?? undefined
318
+ const result = await mediaAdapter.list({ limit, cursor })
319
+ sendJson(res, result)
320
+ return
321
+ }
322
+
323
+ // POST /_nua/cms/media/upload
324
+ if (route === 'media/upload' && req.method === 'POST') {
325
+ if (!mediaAdapter) {
326
+ sendError(res, 'Media storage not configured', 501)
327
+ return
328
+ }
329
+ const contentType = req.headers['content-type'] ?? ''
330
+ if (!contentType.includes('multipart/form-data')) {
331
+ sendError(res, 'Expected multipart/form-data')
332
+ return
333
+ }
334
+ // 50 MB limit for file uploads
335
+ const body = await readBody(req, 50 * 1024 * 1024)
336
+ const file = parseMultipartFile(body, contentType)
337
+ if (!file) {
338
+ sendError(res, 'No file found in request')
339
+ return
340
+ }
341
+
342
+ // Validate file content type — allow images, videos, PDFs, and common web assets
343
+ const allowedTypes = [
344
+ 'image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/avif', 'image/x-icon',
345
+ 'video/mp4', 'video/webm',
346
+ 'application/pdf',
347
+ ]
348
+ // Block SVG (can contain scripts) unless explicitly served with safe headers
349
+ if (!allowedTypes.includes(file.contentType)) {
350
+ sendError(res, `File type not allowed: ${file.contentType}`)
351
+ return
352
+ }
353
+
354
+ const result = await mediaAdapter.upload(file.buffer, file.filename, file.contentType)
355
+ sendJson(res, result)
356
+ return
357
+ }
358
+
359
+ // DELETE /_nua/cms/media/<id> — only match paths with an actual ID segment
360
+ if (route.startsWith('media/') && req.method === 'DELETE') {
361
+ if (!mediaAdapter) {
362
+ sendError(res, 'Media storage not configured', 501)
363
+ return
364
+ }
365
+ const id = route.slice('media/'.length)
366
+ // Don't match known sub-routes like 'list' or 'upload'
367
+ if (!id || id === 'list' || id === 'upload') {
368
+ sendError(res, 'Not found', 404)
369
+ return
370
+ }
371
+ const result = await mediaAdapter.delete(decodeURIComponent(id))
372
+ sendJson(res, result)
373
+ return
374
+ }
375
+
376
+ // GET /_nua/cms/deployment/status
377
+ if (route === 'deployment/status' && req.method === 'GET') {
378
+ sendJson(res, { currentDeployment: null, pendingCount: 0 })
379
+ return
380
+ }
381
+
382
+ sendError(res, 'Not found', 404)
383
+ }
384
+
385
+ async function processHtmlForDev(
386
+ html: string,
387
+ pagePath: string,
388
+ config: Required<CmsMarkerOptions>,
389
+ idCounter: { value: number },
390
+ ) {
391
+ // Clear cached parsed files so variable definitions reflect the latest source
392
+ clearSourceFinderCache()
393
+
394
+ // In dev mode, reset counter per page for consistent IDs during HMR
395
+ let pageCounter = 0
396
+ const idGenerator = () => `cms-${pageCounter++}`
397
+
398
+ // Check if this is a collection page (e.g., /services/example -> services collection, example slug)
399
+ const collectionInfo = await findCollectionSource(pagePath, config.contentDir)
400
+ const isCollectionPage = !!collectionInfo
401
+
402
+ // Parse markdown content if this is a collection page
403
+ let mdContent: Awaited<ReturnType<typeof parseMarkdownContent>> | undefined
404
+ if (collectionInfo) {
405
+ mdContent = await parseMarkdownContent(collectionInfo)
406
+ }
407
+
408
+ // Get the first non-empty line of the markdown body for wrapper detection
409
+ const bodyFirstLine = mdContent?.body
410
+ ?.split('\n')
411
+ .find((line) => line.trim().length > 0)
412
+ ?.trim()
413
+
414
+ const result = await processHtml(
415
+ html,
416
+ pagePath,
417
+ {
418
+ attributeName: config.attributeName,
419
+ includeTags: config.includeTags,
420
+ excludeTags: config.excludeTags,
421
+ includeEmptyText: config.includeEmptyText,
422
+ generateManifest: config.generateManifest,
423
+ markComponents: config.markComponents,
424
+ componentDirs: config.componentDirs,
425
+ // Skip marking markdown-rendered content on collection pages
426
+ // The markdown body is treated as a single editable unit
427
+ skipMarkdownContent: isCollectionPage,
428
+ // Pass collection info for wrapper element marking
429
+ collectionInfo: collectionInfo
430
+ ? { name: collectionInfo.name, slug: collectionInfo.slug, bodyFirstLine, bodyText: mdContent?.body, contentPath: collectionInfo.file }
431
+ : undefined,
432
+ // Pass SEO options
433
+ seo: config.seo,
434
+ },
435
+ idGenerator,
436
+ )
437
+
438
+ // Build collection entry if this is a collection page
439
+ let collectionEntry: CollectionEntry | undefined
440
+ if (collectionInfo && mdContent) {
441
+ collectionEntry = {
442
+ collectionName: mdContent.collectionName,
443
+ collectionSlug: mdContent.collectionSlug,
444
+ sourcePath: mdContent.file,
445
+ frontmatter: mdContent.frontmatter,
446
+ body: mdContent.body,
447
+ bodyStartLine: mdContent.bodyStartLine,
448
+ wrapperId: result.collectionWrapperId,
449
+ }
450
+ }
451
+
452
+ // Ensure the search index is initialized for image source lookups
453
+ // (idempotent - only scans files on first call)
454
+ await initializeSearchIndex()
455
+
456
+ // In dev mode, we use the source info from Astro compiler attributes
457
+ // which is already extracted by html-processor
458
+ // Always search for image source by src value - the sourcePath from HTML attributes
459
+ // may point to a shared Image component rather than the actual usage site
460
+ for (const entry of Object.values(result.entries)) {
461
+ if (entry.imageMetadata?.src) {
462
+ const imageSource = await findImageSourceLocation(entry.imageMetadata.src, entry.imageMetadata.srcSet)
463
+ if (imageSource) {
464
+ entry.sourcePath = imageSource.file
465
+ entry.sourceLine = imageSource.line
466
+ entry.sourceSnippet = imageSource.snippet
467
+ }
468
+ }
469
+ }
470
+
471
+ // Filter out entries without sourcePath - these can't be edited
472
+ const idsToRemove: string[] = []
473
+ for (const [id, entry] of Object.entries(result.entries)) {
474
+ // Keep collection wrapper entries even without sourcePath (they use contentPath)
475
+ if (entry.collectionName) continue
476
+ // Remove entries that don't have a resolved sourcePath
477
+ if (!entry.sourcePath) {
478
+ idsToRemove.push(id)
479
+ delete result.entries[id]
480
+ }
481
+ }
482
+
483
+ // Remove CMS ID attributes from HTML for entries that were filtered out
484
+ let finalHtml = result.html
485
+ if (idsToRemove.length > 0) {
486
+ const root = parse(result.html, {
487
+ lowerCaseTagName: false,
488
+ comment: true,
489
+ })
490
+ for (const id of idsToRemove) {
491
+ const element = root.querySelector(`[${config.attributeName}="${id}"]`)
492
+ if (element) {
493
+ element.removeAttribute(config.attributeName)
494
+ // Also remove related CMS attributes
495
+ element.removeAttribute('data-cms-img')
496
+ element.removeAttribute('data-cms-markdown')
497
+ }
498
+ }
499
+ finalHtml = root.toString()
500
+ }
501
+
502
+ return {
503
+ html: finalHtml,
504
+ entries: result.entries,
505
+ components: result.components,
506
+ collection: collectionEntry,
507
+ seo: result.seo,
508
+ }
509
+ }
510
+
511
+ function mediaMimeFromExt(ext: string): string {
512
+ const map: Record<string, string> = {
513
+ '.jpg': 'image/jpeg',
514
+ '.jpeg': 'image/jpeg',
515
+ '.png': 'image/png',
516
+ '.gif': 'image/gif',
517
+ '.webp': 'image/webp',
518
+ '.avif': 'image/avif',
519
+ '.ico': 'image/x-icon',
520
+ '.mp4': 'video/mp4',
521
+ '.webm': 'video/webm',
522
+ '.pdf': 'application/pdf',
523
+ }
524
+ return map[ext] ?? 'application/octet-stream'
525
+ }