@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,463 @@
1
+ import fs from 'node:fs/promises'
2
+ import path from 'node:path'
3
+ import { getProjectRoot } from '../config'
4
+ import type { ManifestWriter } from '../manifest-writer'
5
+ import type { CmsManifest, ComponentDefinition, ComponentInstance } from '../types'
6
+ import { acquireFileLock, escapeRegex, normalizePagePath, resolveAndValidatePath } from '../utils'
7
+
8
+ export type InsertPosition = 'before' | 'after'
9
+
10
+ export interface InsertComponentRequest {
11
+ position: InsertPosition
12
+ referenceComponentId: string
13
+ componentName: string
14
+ props: Record<string, unknown>
15
+ meta?: { source: string; url: string }
16
+ }
17
+
18
+ export interface InsertComponentResponse {
19
+ success: boolean
20
+ message?: string
21
+ sourceFile?: string
22
+ error?: string
23
+ }
24
+
25
+ export interface RemoveComponentRequest {
26
+ componentId: string
27
+ meta?: { source: string; url: string }
28
+ }
29
+
30
+ export interface RemoveComponentResponse {
31
+ success: boolean
32
+ message?: string
33
+ sourceFile?: string
34
+ error?: string
35
+ }
36
+
37
+ export async function handleInsertComponent(
38
+ request: InsertComponentRequest,
39
+ manifestWriter: ManifestWriter,
40
+ ): Promise<InsertComponentResponse> {
41
+ const { position, referenceComponentId, componentName, props, meta } = request
42
+
43
+ if (!meta?.url) {
44
+ return { success: false, error: 'Page URL is required in meta' }
45
+ }
46
+
47
+ const pagePath = normalizePagePath(meta.url)
48
+ const pageData = manifestWriter.getPageManifest(pagePath)
49
+ if (!pageData) {
50
+ return { success: false, error: 'Page manifest not found' }
51
+ }
52
+
53
+ const manifest: CmsManifest = {
54
+ entries: pageData.entries,
55
+ components: pageData.components,
56
+ componentDefinitions: manifestWriter.getComponentDefinitions(),
57
+ }
58
+
59
+ // Find the reference component
60
+ const referenceComponent = manifest.components[referenceComponentId]
61
+ if (!referenceComponent) {
62
+ return { success: false, error: `Reference component '${referenceComponentId}' not found in manifest` }
63
+ }
64
+
65
+ // Get component definition
66
+ const componentDef = manifest.componentDefinitions[componentName]
67
+ if (!componentDef) {
68
+ return { success: false, error: `Component definition '${componentName}' not found in manifest` }
69
+ }
70
+
71
+ try {
72
+ const projectRoot = getProjectRoot()
73
+
74
+ // Find the invocation file
75
+ const invocation = await findComponentInvocationFile(
76
+ projectRoot,
77
+ meta.url,
78
+ manifest,
79
+ referenceComponent,
80
+ )
81
+
82
+ const filePath = invocation?.filePath
83
+ ?? normalizeFilePath(referenceComponent.invocationSourcePath ?? referenceComponent.sourcePath)
84
+
85
+ const fullPath = resolveAndValidatePath(filePath)
86
+ const release = await acquireFileLock(fullPath)
87
+ try {
88
+ let currentContent: string
89
+ try {
90
+ currentContent = await fs.readFile(fullPath, 'utf-8')
91
+ } catch {
92
+ return { success: false, error: `Source file not found: ${filePath}` }
93
+ }
94
+
95
+ const lines = currentContent.split('\n')
96
+
97
+ let refLineIndex: number
98
+ if (invocation) {
99
+ refLineIndex = invocation.lineIndex
100
+ } else {
101
+ const occurrenceIndex = getComponentOccurrenceIndex(manifest, referenceComponent)
102
+ refLineIndex = findComponentInvocationLine(lines, referenceComponent.componentName, occurrenceIndex)
103
+ if (refLineIndex < 0) {
104
+ refLineIndex = referenceComponent.sourceLine - 1
105
+ }
106
+ }
107
+
108
+ if (refLineIndex < 0 || refLineIndex >= lines.length) {
109
+ return { success: false, error: `Invalid source line for reference component: ${refLineIndex + 1}` }
110
+ }
111
+
112
+ const newComponentJsx = generateComponentJsx(componentName, props, componentDef)
113
+
114
+ const { startLine, endLine } = findComponentBounds(
115
+ lines,
116
+ refLineIndex,
117
+ referenceComponent.componentName,
118
+ )
119
+
120
+ const insertIndex = position === 'before' ? startLine : endLine + 1
121
+ const indentation = getIndentation(lines[startLine]!)
122
+
123
+ const indentedJsx = newComponentJsx
124
+ .split('\n')
125
+ .map((line) => (line.trim() ? indentation + line : line))
126
+ .join('\n')
127
+
128
+ lines.splice(insertIndex, 0, indentedJsx)
129
+ await fs.writeFile(fullPath, lines.join('\n'), 'utf-8')
130
+
131
+ return {
132
+ success: true,
133
+ message: `Successfully inserted ${componentName} ${position} ${referenceComponent.componentName}`,
134
+ sourceFile: filePath,
135
+ }
136
+ } finally {
137
+ release()
138
+ }
139
+ } catch (error) {
140
+ const message = error instanceof Error ? error.message : String(error)
141
+ return { success: false, error: message }
142
+ }
143
+ }
144
+
145
+ export async function handleRemoveComponent(
146
+ request: RemoveComponentRequest,
147
+ manifestWriter: ManifestWriter,
148
+ ): Promise<RemoveComponentResponse> {
149
+ const { componentId, meta } = request
150
+
151
+ if (!meta?.url) {
152
+ return { success: false, error: 'Page URL is required in meta' }
153
+ }
154
+
155
+ const pagePath = normalizePagePath(meta.url)
156
+ const pageData = manifestWriter.getPageManifest(pagePath)
157
+ if (!pageData) {
158
+ return { success: false, error: 'Page manifest not found' }
159
+ }
160
+
161
+ const manifest: CmsManifest = {
162
+ entries: pageData.entries,
163
+ components: pageData.components,
164
+ componentDefinitions: manifestWriter.getComponentDefinitions(),
165
+ }
166
+
167
+ const component = manifest.components[componentId]
168
+ if (!component) {
169
+ return { success: false, error: `Component '${componentId}' not found in manifest` }
170
+ }
171
+
172
+ try {
173
+ const projectRoot = getProjectRoot()
174
+
175
+ const invocation = await findComponentInvocationFile(
176
+ projectRoot,
177
+ meta.url,
178
+ manifest,
179
+ component,
180
+ )
181
+
182
+ const filePath = invocation?.filePath
183
+ ?? normalizeFilePath(component.invocationSourcePath ?? component.sourcePath)
184
+
185
+ const fullPath = resolveAndValidatePath(filePath)
186
+ const release = await acquireFileLock(fullPath)
187
+ try {
188
+ let currentContent: string
189
+ try {
190
+ currentContent = await fs.readFile(fullPath, 'utf-8')
191
+ } catch {
192
+ return { success: false, error: `Source file not found: ${filePath}` }
193
+ }
194
+
195
+ const lines = currentContent.split('\n')
196
+
197
+ let refLineIndex: number
198
+ if (invocation) {
199
+ refLineIndex = invocation.lineIndex
200
+ } else {
201
+ const occurrenceIndex = getComponentOccurrenceIndex(manifest, component)
202
+ refLineIndex = findComponentInvocationLine(lines, component.componentName, occurrenceIndex)
203
+ if (refLineIndex < 0) {
204
+ refLineIndex = component.sourceLine - 1
205
+ }
206
+ }
207
+
208
+ if (refLineIndex < 0 || refLineIndex >= lines.length) {
209
+ return { success: false, error: `Invalid source line for component: ${refLineIndex + 1}` }
210
+ }
211
+
212
+ const { startLine, endLine } = findComponentBounds(
213
+ lines,
214
+ refLineIndex,
215
+ component.componentName,
216
+ )
217
+
218
+ let removeCount = endLine - startLine + 1
219
+ if (endLine + 1 < lines.length && lines[endLine + 1]!.trim() === '') {
220
+ removeCount++
221
+ }
222
+
223
+ lines.splice(startLine, removeCount)
224
+ await fs.writeFile(fullPath, lines.join('\n'), 'utf-8')
225
+
226
+ return {
227
+ success: true,
228
+ message: `Successfully removed ${component.componentName} component`,
229
+ sourceFile: filePath,
230
+ }
231
+ } finally {
232
+ release()
233
+ }
234
+ } catch (error) {
235
+ const message = error instanceof Error ? error.message : String(error)
236
+ return { success: false, error: message }
237
+ }
238
+ }
239
+
240
+ // --- Helper functions ported from CmsComponentHandler ---
241
+
242
+ function findComponentBounds(
243
+ lines: string[],
244
+ startLineIndex: number,
245
+ componentName: string,
246
+ ): { startLine: number; endLine: number } {
247
+ const startLine = startLineIndex
248
+
249
+ // Check if the opening tag is self-closing (may span multiple lines)
250
+ // Scan from startLine forward until we find either '/>' or '>' to determine tag style
251
+ let tagClosed = false
252
+ for (let i = startLineIndex; i < lines.length; i++) {
253
+ const currentLine = lines[i]!
254
+ // Check for self-closing '/>' before any '>' on this line
255
+ const selfCloseIdx = currentLine.indexOf('/>')
256
+ const openEndIdx = currentLine.indexOf('>')
257
+
258
+ if (selfCloseIdx >= 0 && (openEndIdx < 0 || selfCloseIdx <= openEndIdx)) {
259
+ // Self-closing tag found
260
+ return { startLine, endLine: i }
261
+ }
262
+ if (openEndIdx >= 0) {
263
+ // Opening tag closed with '>' (not self-closing)
264
+ tagClosed = true
265
+ break
266
+ }
267
+ }
268
+
269
+ // If the tag never closed, return just the start line
270
+ if (!tagClosed) {
271
+ return { startLine, endLine: startLineIndex }
272
+ }
273
+
274
+ const escapedName = escapeRegex(componentName)
275
+ const closingTag = `</${componentName}>`
276
+ let depth = 1
277
+ let endLine = startLineIndex
278
+
279
+ for (let i = startLineIndex + 1; i < lines.length; i++) {
280
+ const currentLine = lines[i]!
281
+
282
+ const openingMatches = currentLine.match(new RegExp(`<${escapedName}(?:\\s|>)`, 'g'))
283
+ if (openingMatches) {
284
+ for (const match of openingMatches) {
285
+ const tagStart = currentLine.indexOf(match)
286
+ const restOfTag = currentLine.slice(tagStart)
287
+ if (!restOfTag.includes('/>') || restOfTag.indexOf('/>') > restOfTag.indexOf('>')) {
288
+ depth++
289
+ }
290
+ }
291
+ }
292
+
293
+ const closingMatches = currentLine.match(new RegExp(escapeRegex(closingTag), 'g'))
294
+ if (closingMatches) {
295
+ depth -= closingMatches.length
296
+ }
297
+
298
+ if (depth <= 0) {
299
+ endLine = i
300
+ break
301
+ }
302
+ }
303
+
304
+ return { startLine, endLine }
305
+ }
306
+
307
+ function getPageFileCandidates(pageUrl: string): string[] {
308
+ let pathname: string
309
+ try {
310
+ const url = new URL(pageUrl)
311
+ pathname = url.pathname
312
+ } catch {
313
+ pathname = pageUrl.split('?')[0]?.split('#')[0] ?? '/'
314
+ }
315
+
316
+ if (pathname.length > 1 && pathname.endsWith('/')) {
317
+ pathname = pathname.slice(0, -1)
318
+ }
319
+
320
+ if (pathname === '/' || pathname === '') {
321
+ return ['src/pages/index.astro', 'src/pages/index.mdx']
322
+ }
323
+
324
+ const p = pathname.slice(1)
325
+ return [
326
+ `src/pages/${p}.astro`,
327
+ `src/pages/${p}/index.astro`,
328
+ `src/pages/${p}.mdx`,
329
+ `src/pages/${p}/index.mdx`,
330
+ ]
331
+ }
332
+
333
+ function getComponentOccurrenceIndex(
334
+ manifest: CmsManifest,
335
+ referenceComponent: ComponentInstance,
336
+ ): number {
337
+ if (referenceComponent.invocationIndex !== undefined) {
338
+ return referenceComponent.invocationIndex
339
+ }
340
+
341
+ const componentName = referenceComponent.componentName
342
+ const invocationSource = referenceComponent.invocationSourcePath
343
+ const sameNameComponents = Object.values(manifest.components)
344
+ .filter(c =>
345
+ c.componentName === componentName
346
+ && (!invocationSource || c.invocationSourcePath === invocationSource),
347
+ )
348
+
349
+ const index = sameNameComponents.findIndex(c => c.id === referenceComponent.id)
350
+ return index >= 0 ? index : 0
351
+ }
352
+
353
+ async function findComponentInvocationFile(
354
+ projectRoot: string,
355
+ pageUrl: string,
356
+ manifest: CmsManifest,
357
+ referenceComponent: ComponentInstance,
358
+ ): Promise<{ filePath: string; lineIndex: number } | null> {
359
+ // If manifest provides invocationSourcePath, use it directly
360
+ if (referenceComponent.invocationSourcePath) {
361
+ const filePath = normalizeFilePath(referenceComponent.invocationSourcePath)
362
+ const fullPath = path.resolve(projectRoot, filePath)
363
+ try {
364
+ const content = await fs.readFile(fullPath, 'utf-8')
365
+ const lines = content.split('\n')
366
+ const lineIndex = findComponentInvocationLine(
367
+ lines,
368
+ referenceComponent.componentName,
369
+ referenceComponent.invocationIndex ?? 0,
370
+ )
371
+ if (lineIndex >= 0) {
372
+ return { filePath, lineIndex }
373
+ }
374
+ } catch {
375
+ // File not found, fall through to candidates
376
+ }
377
+ }
378
+
379
+ // Derive page file path from URL and search for the component invocation
380
+ const candidates = getPageFileCandidates(pageUrl)
381
+ const occurrenceIndex = getComponentOccurrenceIndex(manifest, referenceComponent)
382
+
383
+ for (const candidate of candidates) {
384
+ const fullPath = path.resolve(projectRoot, candidate)
385
+ try {
386
+ const content = await fs.readFile(fullPath, 'utf-8')
387
+ const lines = content.split('\n')
388
+ const lineIndex = findComponentInvocationLine(
389
+ lines,
390
+ referenceComponent.componentName,
391
+ occurrenceIndex,
392
+ )
393
+ if (lineIndex >= 0) {
394
+ return { filePath: candidate, lineIndex }
395
+ }
396
+ } catch {
397
+ // File not found, try next candidate
398
+ }
399
+ }
400
+
401
+ return null
402
+ }
403
+
404
+ function findComponentInvocationLine(
405
+ lines: string[],
406
+ componentName: string,
407
+ occurrenceIndex: number,
408
+ ): number {
409
+ const pattern = new RegExp(`<${escapeRegex(componentName)}(?:\\s|>|/>)`)
410
+ let found = 0
411
+ for (let i = 0; i < lines.length; i++) {
412
+ if (pattern.test(lines[i]!)) {
413
+ if (found === occurrenceIndex) return i
414
+ found++
415
+ }
416
+ }
417
+ return found > 0 ? findComponentInvocationLine(lines, componentName, 0) : -1
418
+ }
419
+
420
+ function generateComponentJsx(
421
+ componentName: string,
422
+ props: Record<string, unknown>,
423
+ _definition: ComponentDefinition,
424
+ ): string {
425
+ const propsString = Object.entries(props)
426
+ .map(([key, value]) => {
427
+ if (typeof value === 'string') {
428
+ return `${key}="${escapeHtml(value)}"`
429
+ }
430
+ if (typeof value === 'boolean') {
431
+ return value ? key : `${key}={false}`
432
+ }
433
+ if (typeof value === 'number') {
434
+ return `${key}={${value}}`
435
+ }
436
+ return `${key}={${JSON.stringify(value)}}`
437
+ })
438
+ .join(' ')
439
+
440
+ if (propsString) {
441
+ return `<${componentName} ${propsString} />`
442
+ }
443
+ return `<${componentName} />`
444
+ }
445
+
446
+ function escapeHtml(str: string): string {
447
+ return str
448
+ .replace(/&/g, '&amp;')
449
+ .replace(/"/g, '&quot;')
450
+ .replace(/'/g, '&#39;')
451
+ .replace(/</g, '&lt;')
452
+ .replace(/>/g, '&gt;')
453
+ }
454
+
455
+ function getIndentation(line: string): string {
456
+ const match = line.match(/^(\s*)/)
457
+ return match ? match[1]! : ''
458
+ }
459
+
460
+ function normalizeFilePath(p: string): string {
461
+ return p.startsWith('/') ? p.slice(1) : p
462
+ }
463
+
@@ -0,0 +1,202 @@
1
+ import fs from 'node:fs/promises'
2
+ import path from 'node:path'
3
+ import yaml from 'yaml'
4
+ import { getProjectRoot } from '../config'
5
+ import { acquireFileLock, resolveAndValidatePath as sharedResolveAndValidatePath } from '../utils'
6
+
7
+ export interface BlogFrontmatter {
8
+ title: string
9
+ date: string
10
+ author?: string
11
+ categories?: string[]
12
+ excerpt?: string
13
+ featuredImage?: string
14
+ draft?: boolean
15
+ [key: string]: unknown
16
+ }
17
+
18
+ export interface CreateMarkdownRequest {
19
+ collection: string
20
+ title: string
21
+ slug: string
22
+ frontmatter?: Partial<BlogFrontmatter>
23
+ content?: string
24
+ }
25
+
26
+ export interface CreateMarkdownResponse {
27
+ success: boolean
28
+ filePath?: string
29
+ slug?: string
30
+ error?: string
31
+ }
32
+
33
+ export interface UpdateMarkdownRequest {
34
+ filePath: string
35
+ frontmatter?: Partial<BlogFrontmatter>
36
+ content?: string
37
+ }
38
+
39
+ export interface UpdateMarkdownResponse {
40
+ success: boolean
41
+ error?: string
42
+ }
43
+
44
+ export interface GetMarkdownContentResponse {
45
+ content: string
46
+ frontmatter: BlogFrontmatter
47
+ filePath: string
48
+ }
49
+
50
+ export async function handleGetMarkdownContent(
51
+ filePath: string,
52
+ ): Promise<GetMarkdownContentResponse | null> {
53
+ try {
54
+ const fullPath = resolveAndValidatePath(filePath)
55
+ const raw = await fs.readFile(fullPath, 'utf-8')
56
+ const { frontmatter, content } = parseFrontmatter(raw)
57
+
58
+ return {
59
+ content,
60
+ frontmatter: frontmatter as BlogFrontmatter,
61
+ filePath,
62
+ }
63
+ } catch {
64
+ return null
65
+ }
66
+ }
67
+
68
+ export async function handleUpdateMarkdown(
69
+ request: UpdateMarkdownRequest,
70
+ ): Promise<UpdateMarkdownResponse> {
71
+ try {
72
+ const fullPath = resolveAndValidatePath(request.filePath)
73
+ const release = await acquireFileLock(fullPath)
74
+ try {
75
+ const raw = await fs.readFile(fullPath, 'utf-8')
76
+ const existing = parseFrontmatter(raw)
77
+
78
+ const mergedFrontmatter: BlogFrontmatter = {
79
+ ...(existing.frontmatter as BlogFrontmatter),
80
+ ...request.frontmatter,
81
+ }
82
+
83
+ const finalContent = request.content ?? existing.content
84
+ const markdownContent = serializeFrontmatter(mergedFrontmatter, finalContent)
85
+
86
+ await fs.writeFile(fullPath, markdownContent, 'utf-8')
87
+
88
+ return { success: true }
89
+ } finally {
90
+ release()
91
+ }
92
+ } catch (error) {
93
+ const message = error instanceof Error ? error.message : String(error)
94
+ return { success: false, error: message }
95
+ }
96
+ }
97
+
98
+ export async function handleCreateMarkdown(
99
+ request: CreateMarkdownRequest,
100
+ ): Promise<CreateMarkdownResponse> {
101
+ const { collection, title, slug, frontmatter = {}, content = '' } = request
102
+
103
+ const normalizedSlug = slugify(slug || title)
104
+ if (!normalizedSlug) {
105
+ return { success: false, error: 'Could not generate a valid slug from the provided title/slug' }
106
+ }
107
+ const filePath = `src/content/${collection}/${normalizedSlug}.md`
108
+ const fullPath = resolveAndValidatePath(filePath)
109
+
110
+ const fullFrontmatter: BlogFrontmatter = {
111
+ title,
112
+ date: new Date().toISOString().split('T')[0]!,
113
+ draft: true,
114
+ ...frontmatter,
115
+ }
116
+
117
+ const markdownContent = serializeFrontmatter(fullFrontmatter, content)
118
+
119
+ try {
120
+ await fs.mkdir(path.dirname(fullPath), { recursive: true })
121
+ // Use 'wx' flag for atomic exclusive create — fails if file already exists
122
+ await fs.writeFile(fullPath, markdownContent, { encoding: 'utf-8', flag: 'wx' })
123
+
124
+ return {
125
+ success: true,
126
+ filePath,
127
+ slug: normalizedSlug,
128
+ }
129
+ } catch (error) {
130
+ if (error instanceof Error && 'code' in error && (error as NodeJS.ErrnoException).code === 'EEXIST') {
131
+ return { success: false, error: `File already exists: ${filePath}` }
132
+ }
133
+ const message = error instanceof Error ? error.message : String(error)
134
+ return { success: false, error: message }
135
+ }
136
+ }
137
+
138
+ // --- Internal helpers ---
139
+
140
+ /**
141
+ * Resolve a user-provided file path and ensure it stays within the project root.
142
+ * Throws if the resolved path escapes the project boundary.
143
+ */
144
+ function resolveAndValidatePath(filePath: string): string {
145
+ const projectRoot = getProjectRoot()
146
+ const normalizedPath = filePath.startsWith('/') ? filePath.slice(1) : filePath
147
+ const fullPath = path.resolve(projectRoot, normalizedPath)
148
+
149
+ // Ensure the resolved path is within the project root
150
+ const resolvedRoot = path.resolve(projectRoot)
151
+ if (!fullPath.startsWith(resolvedRoot + path.sep) && fullPath !== resolvedRoot) {
152
+ throw new Error(`Path traversal detected: ${filePath}`)
153
+ }
154
+
155
+ return fullPath
156
+ }
157
+
158
+ function parseFrontmatter(raw: string): { frontmatter: Record<string, unknown>; content: string } {
159
+ const trimmed = raw.trimStart()
160
+ if (!trimmed.startsWith('---')) {
161
+ return { frontmatter: {}, content: raw }
162
+ }
163
+
164
+ // Find closing --- on its own line (not inside YAML values)
165
+ const lines = trimmed.split('\n')
166
+ let endLineIndex = -1
167
+ for (let i = 1; i < lines.length; i++) {
168
+ if (lines[i]!.trimEnd() === '---') {
169
+ endLineIndex = i
170
+ break
171
+ }
172
+ }
173
+ if (endLineIndex === -1) {
174
+ return { frontmatter: {}, content: raw }
175
+ }
176
+
177
+ const yamlStr = lines.slice(1, endLineIndex).join('\n').trim()
178
+ const content = lines.slice(endLineIndex + 1).join('\n').replace(/^\r?\n/, '')
179
+
180
+ let frontmatter: Record<string, unknown> = {}
181
+ try {
182
+ frontmatter = (yaml.parse(yamlStr) as Record<string, unknown>) ?? {}
183
+ } catch {
184
+ // Invalid YAML, return empty frontmatter
185
+ }
186
+
187
+ return { frontmatter, content }
188
+ }
189
+
190
+ function serializeFrontmatter(frontmatter: Record<string, unknown>, content: string): string {
191
+ const yamlStr = yaml.stringify(frontmatter).trim()
192
+ return `---\n${yamlStr}\n---\n${content}`
193
+ }
194
+
195
+ function slugify(text: string): string {
196
+ return text
197
+ .toLowerCase()
198
+ .trim()
199
+ .replace(/[^\w\s-]/g, '')
200
+ .replace(/[\s_-]+/g, '-')
201
+ .replace(/^-+|-+$/g, '')
202
+ }