@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,784 @@
1
+ import type { AstroIntegrationLogger } from 'astro'
2
+ import { parse } from 'node-html-parser'
3
+ import fs from 'node:fs/promises'
4
+ import path from 'node:path'
5
+ import { fileURLToPath } from 'node:url'
6
+ import { getProjectRoot } from './config'
7
+ import { extractComponentName, processHtml } from './html-processor'
8
+ import type { ManifestWriter } from './manifest-writer'
9
+ import { generateComponentPreviews } from './preview-generator'
10
+ import {
11
+ clearSourceFinderCache,
12
+ extractOpeningTagWithLine,
13
+ findCollectionSource,
14
+ findImageSourceLocation,
15
+ findMarkdownSourceLocation,
16
+ findSourceLocation,
17
+ initializeSearchIndex,
18
+ parseMarkdownContent,
19
+ updateAttributeSources,
20
+ updateColorClassSources,
21
+ } from './source-finder'
22
+ import type { CmsMarkerOptions, CollectionEntry } from './types'
23
+
24
+ // Concurrency limit for parallel processing
25
+ const MAX_CONCURRENT = 10
26
+
27
+ /**
28
+ * Get the page path from an HTML file path
29
+ * For example: /about/index.html -> /about
30
+ * /index.html -> /
31
+ * /blog/post.html -> /blog/post
32
+ */
33
+ function getPagePath(htmlPath: string, outDir: string): string {
34
+ const relPath = path.relative(outDir, htmlPath)
35
+ const parts = relPath.split(path.sep)
36
+
37
+ // Handle index.html files
38
+ if (parts[parts.length - 1] === 'index.html') {
39
+ parts.pop()
40
+ return '/' + parts.join('/')
41
+ }
42
+
43
+ // Handle other .html files (remove extension)
44
+ const last = parts[parts.length - 1]
45
+ if (last) {
46
+ parts[parts.length - 1] = last.replace('.html', '')
47
+ }
48
+ return '/' + parts.join('/')
49
+ }
50
+
51
+ /**
52
+ * Cluster entries from the same source file into separate component instances.
53
+ * When a component is used multiple times on a page, its entries are in different
54
+ * subtrees. We partition by finding which direct child of the LCA each entry belongs to.
55
+ */
56
+ export function clusterComponentEntries<T>(
57
+ elements: T[],
58
+ entryIds: string[],
59
+ findLCA: (els: T[]) => T | null,
60
+ ): Array<{ clusterEntryIds: string[]; clusterElements: T[] }> {
61
+ if (elements.length <= 1) {
62
+ return [{ clusterEntryIds: [...entryIds], clusterElements: [...elements] }]
63
+ }
64
+
65
+ const lca = findLCA(elements)
66
+ if (!lca) {
67
+ return [{ clusterEntryIds: [...entryIds], clusterElements: [...elements] }]
68
+ }
69
+
70
+ // If any entry is a direct child of the LCA, the LCA is the component
71
+ // root itself — don't split its content into separate instances.
72
+ // Only split when ALL entries are behind intermediate wrapper elements.
73
+ const anyDirectChild = elements.some(
74
+ (el: any) => el.parentNode === lca,
75
+ )
76
+ if (anyDirectChild) {
77
+ return [{ clusterEntryIds: [...entryIds], clusterElements: [...elements] }]
78
+ }
79
+
80
+ // Group entries by which direct child of the LCA they fall under.
81
+ // Entries under different intermediate subtrees belong to different instances.
82
+ const childGroups = new Map<unknown, { clusterEntryIds: string[]; clusterElements: T[] }>()
83
+
84
+ for (let i = 0; i < elements.length; i++) {
85
+ let current: any = elements[i]
86
+ while (current && current.parentNode !== lca) {
87
+ current = current.parentNode
88
+ }
89
+ if (!current) continue
90
+
91
+ const existing = childGroups.get(current)
92
+ if (existing) {
93
+ existing.clusterEntryIds.push(entryIds[i]!)
94
+ existing.clusterElements.push(elements[i]!)
95
+ } else {
96
+ childGroups.set(current, {
97
+ clusterEntryIds: [entryIds[i]!],
98
+ clusterElements: [elements[i]!],
99
+ })
100
+ }
101
+ }
102
+
103
+ if (childGroups.size > 1) {
104
+ // Multiple subtrees → each is a separate component instance
105
+ return Array.from(childGroups.values())
106
+ }
107
+
108
+ // All entries are in the same subtree → single instance
109
+ return [{ clusterEntryIds: [...entryIds], clusterElements: [...elements] }]
110
+ }
111
+
112
+ interface PageComponentInvocation {
113
+ componentName: string
114
+ sourceFile: string
115
+ /** Template offset for ordering invocations */
116
+ offset: number
117
+ }
118
+
119
+ /**
120
+ * Find the .astro source file for a page given its URL path.
121
+ */
122
+ async function findPageSource(pagePath: string): Promise<string | null> {
123
+ const projectRoot = getProjectRoot()
124
+ const candidates: string[] = []
125
+
126
+ if (pagePath === '/' || pagePath === '') {
127
+ candidates.push(path.join(projectRoot, 'src/pages/index.astro'))
128
+ } else {
129
+ const cleanPath = pagePath.replace(/^\//, '')
130
+ candidates.push(
131
+ path.join(projectRoot, `src/pages/${cleanPath}.astro`),
132
+ path.join(projectRoot, `src/pages/${cleanPath}/index.astro`),
133
+ )
134
+ }
135
+
136
+ for (const candidate of candidates) {
137
+ try {
138
+ await fs.access(candidate)
139
+ return candidate
140
+ } catch {}
141
+ }
142
+ return null
143
+ }
144
+
145
+ /**
146
+ * Parse an .astro page source file to find component invocations.
147
+ * Returns an ordered list of component usages (including duplicates).
148
+ */
149
+ async function parseComponentInvocations(
150
+ pageSourcePath: string,
151
+ componentDirs: string[],
152
+ ): Promise<PageComponentInvocation[]> {
153
+ const content = await fs.readFile(pageSourcePath, 'utf-8')
154
+ const projectRoot = getProjectRoot()
155
+ const pageDir = path.dirname(pageSourcePath)
156
+
157
+ // Split frontmatter from template
158
+ const fmMatch = content.match(/^---\r?\n([\s\S]*?)\r?\n---/)
159
+ if (!fmMatch) return []
160
+ const frontmatter = fmMatch[1]!
161
+ const templateStart = fmMatch[0].length
162
+ const template = content.slice(templateStart)
163
+
164
+ // Parse import statements to map component names to source files
165
+ const imports = new Map<string, string>() // componentName -> relative source path
166
+ const importRegex = /import\s+(\w+)\s+from\s+['"]([^'"]+)['"]/g
167
+ let match: RegExpMatchArray | null
168
+ while ((match = importRegex.exec(frontmatter)) !== null) {
169
+ const name = match[1]!
170
+ const importPath = match[2]!
171
+
172
+ // Resolve the import path relative to the page file
173
+ const resolved = path.resolve(pageDir, importPath)
174
+ const relToProject = path.relative(projectRoot, resolved)
175
+
176
+ // Check if it's in a component directory
177
+ const isComponent = componentDirs.some(dir => {
178
+ const d = dir.replace(/^\/+|\/+$/g, '')
179
+ return relToProject.startsWith(d + '/') || relToProject.startsWith(d + path.sep)
180
+ })
181
+ if (isComponent) {
182
+ imports.set(name, relToProject)
183
+ }
184
+ }
185
+
186
+ if (imports.size === 0) return []
187
+
188
+ // Find component invocations in the template (both self-closing and paired tags)
189
+ const invocations: PageComponentInvocation[] = []
190
+ for (const [componentName, sourceFile] of imports) {
191
+ const tagRegex = new RegExp(`<${componentName}[\\s/>]`, 'g')
192
+ let tagMatch: RegExpExecArray | null
193
+ while ((tagMatch = tagRegex.exec(template)) !== null) {
194
+ invocations.push({
195
+ componentName,
196
+ sourceFile,
197
+ offset: tagMatch.index,
198
+ })
199
+ }
200
+ }
201
+
202
+ // Sort by position in template (invocation order)
203
+ invocations.sort((a, b) => a.offset - b.offset)
204
+
205
+ return invocations
206
+ }
207
+
208
+ /**
209
+ * Detect components that have no text entries by parsing the page source file.
210
+ * After entry-based components are detected, this finds any remaining component
211
+ * invocations and assigns them to unclaimed DOM elements using invocation order.
212
+ */
213
+ async function detectEntrylessComponents(
214
+ pagePath: string,
215
+ root: ReturnType<typeof parse>,
216
+ components: Record<string, import('./types').ComponentInstance>,
217
+ componentDirs: string[],
218
+ relPath: string,
219
+ idGenerator: () => string,
220
+ markComponentRoot: (el: any, sourceFile: string, entryIds: string[]) => void,
221
+ ): Promise<void> {
222
+ const pageSourcePath = await findPageSource(pagePath)
223
+ if (!pageSourcePath) return
224
+
225
+ const invocations = await parseComponentInvocations(pageSourcePath, componentDirs)
226
+ if (invocations.length === 0) return
227
+
228
+ // Collect all detected component root elements in DOM order
229
+ const detectedRoots: Array<{ el: any; componentName: string }> = []
230
+ const compEls = root.querySelectorAll('[data-cms-component-id]')
231
+ for (const el of compEls) {
232
+ const compId = el.getAttribute('data-cms-component-id')
233
+ if (compId && components[compId]) {
234
+ detectedRoots.push({ el, componentName: components[compId].componentName })
235
+ }
236
+ }
237
+
238
+ if (detectedRoots.length === 0 && invocations.length === 0) return
239
+
240
+ // Find the container: parent of all detected component roots
241
+ // If no components detected yet, we can't determine the container
242
+ if (detectedRoots.length === 0) return
243
+
244
+ const container = detectedRoots[0]?.el.parentNode
245
+ if (!container || !container.childNodes) return
246
+
247
+ // Verify all detected roots share the same parent
248
+ const allSameParent = detectedRoots.every(r => r.el.parentNode === container)
249
+ if (!allSameParent) return
250
+
251
+ // Get the container's element children in DOM order
252
+ const containerChildren: any[] = []
253
+ for (const child of container.childNodes) {
254
+ // Only consider element nodes (nodeType 1)
255
+ if (child.nodeType === 1) {
256
+ containerChildren.push(child)
257
+ }
258
+ }
259
+
260
+ // Build a paired mapping between invocations and container children.
261
+ // Detected components serve as anchor points; undetected children between
262
+ // anchors are assigned to the corresponding unmatched invocations in order.
263
+
264
+ // First, find anchor points: container children that are already detected
265
+ const anchorMap = new Map<number, string>() // childIdx → componentName
266
+ for (let ci = 0; ci < containerChildren.length; ci++) {
267
+ const compId = containerChildren[ci].getAttribute?.('data-cms-component-id')
268
+ if (compId && components[compId]) {
269
+ anchorMap.set(ci, components[compId].componentName)
270
+ }
271
+ }
272
+
273
+ // Walk both lists, using anchors to stay in sync
274
+ let invIdx = 0
275
+ for (let ci = 0; ci < containerChildren.length && invIdx < invocations.length; ci++) {
276
+ const anchorName = anchorMap.get(ci)
277
+
278
+ if (anchorName) {
279
+ // This child is a detected component. Find the matching invocation.
280
+ while (invIdx < invocations.length && invocations[invIdx]!.componentName !== anchorName) {
281
+ invIdx++
282
+ }
283
+ if (invIdx < invocations.length) {
284
+ invIdx++ // consume the matched invocation
285
+ }
286
+ } else {
287
+ // Undetected child - assign it to the current invocation
288
+ const inv = invocations[invIdx]!
289
+ // Only assign if the invocation's component isn't already detected at a later anchor
290
+ markComponentRoot(containerChildren[ci], inv.sourceFile, [])
291
+ invIdx++
292
+ }
293
+ }
294
+ }
295
+
296
+ /**
297
+ * Process a single HTML file
298
+ */
299
+ async function processFile(
300
+ filePath: string,
301
+ outDir: string,
302
+ config: Required<CmsMarkerOptions>,
303
+ manifestWriter: ManifestWriter,
304
+ idCounter: { value: number },
305
+ ): Promise<number> {
306
+ const relPath = path.relative(outDir, filePath)
307
+ const pagePath = getPagePath(filePath, outDir)
308
+ const html = await fs.readFile(filePath, 'utf-8')
309
+
310
+ // First, try to detect if this page is from a content collection
311
+ // We need to know this BEFORE processing HTML to skip marking markdown-rendered elements
312
+ const collectionInfo = await findCollectionSource(pagePath, config.contentDir)
313
+ const isCollectionPage = !!collectionInfo
314
+
315
+ // Parse markdown content early if this is a collection page
316
+ // We need the body content to find the wrapper element during HTML processing
317
+ let mdContent: Awaited<ReturnType<typeof parseMarkdownContent>> | undefined
318
+ if (collectionInfo) {
319
+ mdContent = await parseMarkdownContent(collectionInfo)
320
+ }
321
+
322
+ // Get the first non-empty line of the markdown body for wrapper detection
323
+ const bodyFirstLine = mdContent?.body
324
+ ?.split('\n')
325
+ .find((line) => line.trim().length > 0)
326
+ ?.trim()
327
+
328
+ // Create ID generator - use atomic increment
329
+ const pageIdStart = idCounter.value
330
+ const idGenerator = () => `cms-${idCounter.value++}`
331
+
332
+ const result = await processHtml(
333
+ html,
334
+ relPath,
335
+ {
336
+ attributeName: config.attributeName,
337
+ includeTags: config.includeTags,
338
+ excludeTags: config.excludeTags,
339
+ includeEmptyText: config.includeEmptyText,
340
+ generateManifest: config.generateManifest,
341
+ markComponents: config.markComponents,
342
+ componentDirs: config.componentDirs,
343
+ // Skip marking markdown-rendered content on collection pages
344
+ // The markdown body is treated as a single editable unit
345
+ skipMarkdownContent: isCollectionPage,
346
+ // Pass collection info for wrapper element marking
347
+ collectionInfo: collectionInfo
348
+ ? { name: collectionInfo.name, slug: collectionInfo.slug, bodyFirstLine, bodyText: mdContent?.body, contentPath: collectionInfo.file }
349
+ : undefined,
350
+ // Pass SEO options
351
+ seo: config.seo,
352
+ },
353
+ idGenerator,
354
+ )
355
+
356
+ // During build, source location attributes are not injected by astro-transform.ts
357
+ // (disabled to avoid Vite parse errors). Use findSourceLocation to look up source files.
358
+
359
+ let collectionEntry: CollectionEntry | undefined
360
+
361
+ // Build collection entry if this is a collection page
362
+ if (collectionInfo && mdContent) {
363
+ collectionEntry = {
364
+ collectionName: mdContent.collectionName,
365
+ collectionSlug: mdContent.collectionSlug,
366
+ sourcePath: mdContent.file,
367
+ frontmatter: mdContent.frontmatter,
368
+ body: mdContent.body,
369
+ bodyStartLine: mdContent.bodyStartLine,
370
+ wrapperId: result.collectionWrapperId,
371
+ }
372
+ }
373
+
374
+ // Process entries in parallel for better performance
375
+ const entryLookups = Object.values(result.entries).map(async (entry) => {
376
+ // Handle image entries specially - always search by image src
377
+ // The sourcePath from HTML attributes may point to a shared Image component
378
+ // rather than the file that actually uses the component with the src value
379
+ if (entry.imageMetadata?.src) {
380
+ const imageSource = await findImageSourceLocation(entry.imageMetadata.src, entry.imageMetadata.srcSet)
381
+ if (imageSource) {
382
+ entry.sourcePath = imageSource.file
383
+ entry.sourceLine = imageSource.line
384
+ entry.sourceSnippet = imageSource.snippet
385
+ }
386
+ return
387
+ }
388
+
389
+ // Skip entries that already have source info from component detection
390
+ if (entry.sourcePath && !entry.sourcePath.endsWith('.html')) {
391
+ return
392
+ }
393
+
394
+ // Try to find source in collection markdown frontmatter first
395
+ if (collectionInfo) {
396
+ const mdSource = await findMarkdownSourceLocation(entry.text, collectionInfo)
397
+ if (mdSource) {
398
+ entry.sourcePath = mdSource.file
399
+ entry.sourceLine = mdSource.line
400
+ entry.sourceSnippet = mdSource.snippet
401
+ entry.variableName = mdSource.variableName
402
+ entry.collectionName = mdSource.collectionName
403
+ entry.collectionSlug = mdSource.collectionSlug
404
+ return
405
+ }
406
+ }
407
+
408
+ // Fall back to searching Astro files
409
+ const sourceLocation = await findSourceLocation(entry.text, entry.tag)
410
+ if (sourceLocation) {
411
+ entry.sourcePath = sourceLocation.file
412
+ entry.sourceLine = sourceLocation.line
413
+ entry.sourceSnippet = sourceLocation.snippet
414
+ entry.variableName = sourceLocation.variableName
415
+
416
+ // Update attribute and colorClasses source information if we have an opening tag
417
+ if (sourceLocation.openingTagSnippet) {
418
+ const filePath = path.isAbsolute(sourceLocation.file)
419
+ ? sourceLocation.file
420
+ : path.join(getProjectRoot(), sourceLocation.file)
421
+ try {
422
+ const content = await fs.readFile(filePath, 'utf-8')
423
+ const lines = content.split('\n')
424
+ const tagInfo = extractOpeningTagWithLine(lines, sourceLocation.line - 1, entry.tag)
425
+ const startLine = tagInfo ? tagInfo.startLine + 1 : undefined
426
+
427
+ if (entry.attributes) {
428
+ entry.attributes = await updateAttributeSources(
429
+ sourceLocation.openingTagSnippet,
430
+ entry.attributes,
431
+ sourceLocation.file,
432
+ startLine,
433
+ lines,
434
+ )
435
+ }
436
+ if (entry.colorClasses) {
437
+ entry.colorClasses = updateColorClassSources(
438
+ sourceLocation.openingTagSnippet,
439
+ entry.colorClasses,
440
+ sourceLocation.file,
441
+ startLine,
442
+ lines,
443
+ )
444
+ }
445
+ } catch {
446
+ // Couldn't read file - still update without source lines
447
+ if (entry.attributes) {
448
+ entry.attributes = await updateAttributeSources(
449
+ sourceLocation.openingTagSnippet,
450
+ entry.attributes,
451
+ sourceLocation.file,
452
+ )
453
+ }
454
+ if (entry.colorClasses) {
455
+ entry.colorClasses = updateColorClassSources(
456
+ sourceLocation.openingTagSnippet,
457
+ entry.colorClasses,
458
+ sourceLocation.file,
459
+ )
460
+ }
461
+ }
462
+ }
463
+ }
464
+ })
465
+
466
+ await Promise.all(entryLookups)
467
+
468
+ // Filter out entries without sourcePath - these can't be edited
469
+ const idsToRemove: string[] = []
470
+ for (const [id, entry] of Object.entries(result.entries)) {
471
+ // Keep collection wrapper entries even without sourcePath (they use contentPath)
472
+ if (entry.collectionName) continue
473
+ // Remove entries that don't have a resolved sourcePath
474
+ if (!entry.sourcePath) {
475
+ idsToRemove.push(id)
476
+ delete result.entries[id]
477
+ }
478
+ }
479
+
480
+ // Post-process: detect component roots from resolved entry source paths
481
+ // In production builds, data-astro-source-file is not available so processHtml
482
+ // cannot detect components. We infer them from the resolved sourcePath of entries.
483
+ const componentDirs = config.componentDirs ?? ['src/components']
484
+ const excludeComponentDirs = ['src/pages', 'src/layouts', 'src/layout']
485
+
486
+ if (config.markComponents) {
487
+ // Group entries by their source file (only component files)
488
+ const entriesBySourceFile = new Map<string, string[]>()
489
+ for (const [id, entry] of Object.entries(result.entries)) {
490
+ if (!entry.sourcePath) continue
491
+ const sp = entry.sourcePath
492
+
493
+ const isExcluded = excludeComponentDirs.some(dir => {
494
+ const d = dir.replace(/^\/+|\/+$/g, '')
495
+ return sp.startsWith(d + '/') || sp.includes('/' + d + '/')
496
+ })
497
+ if (isExcluded) continue
498
+
499
+ const isComponent = componentDirs.some(dir => {
500
+ const d = dir.replace(/^\/+|\/+$/g, '')
501
+ return sp.startsWith(d + '/') || sp.includes('/' + d + '/')
502
+ })
503
+ if (!isComponent) continue
504
+
505
+ const existing = entriesBySourceFile.get(sp)
506
+ if (existing) {
507
+ existing.push(id)
508
+ } else {
509
+ entriesBySourceFile.set(sp, [id])
510
+ }
511
+ }
512
+
513
+ const root = parse(result.html, {
514
+ lowerCaseTagName: false,
515
+ comment: true,
516
+ })
517
+
518
+ // Helper: find lowest common ancestor of DOM elements
519
+ type HTMLNode = ReturnType<typeof root.querySelector>
520
+ const findLCA = (elements: NonNullable<HTMLNode>[]): HTMLNode => {
521
+ if (elements.length === 0) return null
522
+ if (elements.length === 1) return elements[0]!
523
+
524
+ const getAncestors = (el: HTMLNode): HTMLNode[] => {
525
+ const ancestors: HTMLNode[] = []
526
+ let current = el?.parentNode as HTMLNode
527
+ while (current) {
528
+ ancestors.unshift(current)
529
+ current = current.parentNode as HTMLNode
530
+ }
531
+ return ancestors
532
+ }
533
+
534
+ const chains = elements.map(el => getAncestors(el))
535
+ const minLen = Math.min(...chains.map(c => c.length))
536
+ let lcaIdx = 0
537
+ for (let i = 0; i < minLen; i++) {
538
+ if (chains.every(chain => chain[i] === chains[0]![i])) {
539
+ lcaIdx = i
540
+ } else {
541
+ break
542
+ }
543
+ }
544
+ return chains[0]![lcaIdx] ?? null
545
+ }
546
+
547
+ // Helper: mark an element as a component root and register the instance
548
+ const markComponentRoot = (
549
+ lca: NonNullable<HTMLNode>,
550
+ sourceFile: string,
551
+ instanceEntryIds: string[],
552
+ ) => {
553
+ if (!('setAttribute' in lca) || !('getAttribute' in lca)) return
554
+ if (lca.getAttribute?.('data-cms-component-id')) return
555
+
556
+ const compId = idGenerator()
557
+ lca.setAttribute('data-cms-component-id', compId)
558
+
559
+ const componentName = extractComponentName(sourceFile)
560
+ const firstEntry = instanceEntryIds.length > 0 ? result.entries[instanceEntryIds[0]!] : undefined
561
+
562
+ result.components[compId] = {
563
+ id: compId,
564
+ componentName,
565
+ file: relPath,
566
+ sourcePath: sourceFile,
567
+ sourceLine: firstEntry?.sourceLine ?? 1,
568
+ props: {},
569
+ }
570
+
571
+ for (const eid of instanceEntryIds) {
572
+ const entry = result.entries[eid]
573
+ if (entry) {
574
+ entry.parentComponentId = compId
575
+ }
576
+ }
577
+ }
578
+
579
+ // For each component source file, cluster entries into separate instances
580
+ // by partitioning them based on which subtree of their common ancestor they belong to
581
+ if (entriesBySourceFile.size > 0) {
582
+ for (const [sourceFile, entryIds] of entriesBySourceFile) {
583
+ const elements = entryIds
584
+ .map(id => root.querySelector(`[${config.attributeName}="${id}"]`))
585
+ .filter((el): el is NonNullable<HTMLNode> => el !== null)
586
+
587
+ if (elements.length === 0) continue
588
+
589
+ // Cluster entries into separate component instances
590
+ const clusters = clusterComponentEntries(elements, entryIds, findLCA)
591
+
592
+ for (const { clusterEntryIds, clusterElements } of clusters) {
593
+ let lca = findLCA(clusterElements)
594
+
595
+ // If the LCA is a text element itself (only one entry),
596
+ // use its parent so the component wraps the element
597
+ if (lca && clusterElements.length === 1 && lca === clusterElements[0]) {
598
+ lca = lca.parentNode as HTMLNode
599
+ }
600
+
601
+ if (!lca) continue
602
+ markComponentRoot(lca, sourceFile, clusterEntryIds)
603
+ }
604
+ }
605
+ }
606
+
607
+ // Detect components without text entries by parsing the page source file
608
+ await detectEntrylessComponents(
609
+ pagePath,
610
+ root,
611
+ result.components,
612
+ componentDirs,
613
+ relPath,
614
+ idGenerator,
615
+ markComponentRoot,
616
+ )
617
+
618
+ // Re-serialize HTML with component markers
619
+ result.html = root.toString()
620
+ }
621
+
622
+ // Remove CMS ID attributes from HTML for entries that were filtered out
623
+ let finalHtml = result.html
624
+ if (idsToRemove.length > 0) {
625
+ const root = parse(result.html, {
626
+ lowerCaseTagName: false,
627
+ comment: true,
628
+ })
629
+ for (const id of idsToRemove) {
630
+ const element = root.querySelector(`[${config.attributeName}="${id}"]`)
631
+ if (element) {
632
+ element.removeAttribute(config.attributeName)
633
+ // Also remove related CMS attributes
634
+ element.removeAttribute('data-cms-img')
635
+ element.removeAttribute('data-cms-markdown')
636
+ }
637
+ }
638
+ finalHtml = root.toString()
639
+ }
640
+
641
+ // Add to manifest writer (handles per-page manifest writes)
642
+ manifestWriter.addPage(pagePath, result.entries, result.components, collectionEntry, result.seo)
643
+
644
+ // Write transformed HTML back
645
+ await fs.writeFile(filePath, finalHtml, 'utf-8')
646
+
647
+ return Object.keys(result.entries).length
648
+ }
649
+
650
+ /** Result of batch processing with error aggregation */
651
+ interface BatchProcessingResult {
652
+ totalEntries: number
653
+ errors: Array<{ file: string; error: Error }>
654
+ }
655
+
656
+ /**
657
+ * Process HTML files in parallel with concurrency limit and error aggregation.
658
+ * Unlike Promise.all, this continues processing even if some files fail.
659
+ */
660
+ async function processFilesInBatches(
661
+ files: string[],
662
+ outDir: string,
663
+ config: Required<CmsMarkerOptions>,
664
+ manifestWriter: ManifestWriter,
665
+ idCounter: { value: number },
666
+ ): Promise<BatchProcessingResult> {
667
+ let totalEntries = 0
668
+ const errors: Array<{ file: string; error: Error }> = []
669
+
670
+ // Process files in batches of MAX_CONCURRENT
671
+ for (let i = 0; i < files.length; i += MAX_CONCURRENT) {
672
+ const batch = files.slice(i, i + MAX_CONCURRENT)
673
+ const results = await Promise.allSettled(
674
+ batch.map(file =>
675
+ processFile(file, outDir, config, manifestWriter, idCounter)
676
+ .then(count => ({ file, count }))
677
+ .catch(err => Promise.reject({ file, error: err }))
678
+ ),
679
+ )
680
+
681
+ for (const result of results) {
682
+ if (result.status === 'fulfilled') {
683
+ totalEntries += result.value.count
684
+ } else {
685
+ const { file, error } = result.reason as { file: string; error: Error }
686
+ errors.push({ file, error })
687
+ }
688
+ }
689
+ }
690
+
691
+ return { totalEntries, errors }
692
+ }
693
+
694
+ /**
695
+ * Process build output - processes all HTML files in parallel.
696
+ * Uses error aggregation to continue processing even if some files fail.
697
+ */
698
+ export async function processBuildOutput(
699
+ dir: URL,
700
+ config: Required<CmsMarkerOptions>,
701
+ manifestWriter: ManifestWriter,
702
+ idCounter: { value: number },
703
+ logger?: AstroIntegrationLogger,
704
+ ): Promise<void> {
705
+ const outDir = fileURLToPath(dir)
706
+ manifestWriter.setOutDir(outDir)
707
+
708
+ // Clear caches from previous builds and initialize search index
709
+ clearSourceFinderCache()
710
+
711
+ const htmlFiles = await findHtmlFiles(outDir)
712
+
713
+ if (htmlFiles.length === 0) {
714
+ logger?.info('No HTML files found to process')
715
+ return
716
+ }
717
+
718
+ const startTime = Date.now()
719
+
720
+ // Pre-build search index for fast source lookups (single pass through all source files)
721
+ await initializeSearchIndex()
722
+
723
+ // Process all files in parallel batches with error aggregation
724
+ const { totalEntries, errors } = await processFilesInBatches(htmlFiles, outDir, config, manifestWriter, idCounter)
725
+
726
+ // Report any errors that occurred during processing
727
+ if (errors.length > 0) {
728
+ const errorLog = logger?.error?.bind(logger) ?? console.error.bind(console)
729
+ errorLog(`[cms] ${errors.length} file(s) failed to process:`)
730
+ for (const { file, error } of errors) {
731
+ const relPath = path.relative(outDir, file)
732
+ errorLog(` - ${relPath}: ${error.message}`)
733
+ }
734
+ }
735
+
736
+ // Generate component preview pages before finalizing manifest
737
+ // (preview URLs are written into componentDefinitions in-place)
738
+ await generateComponentPreviews(
739
+ outDir,
740
+ manifestWriter.getPageDataForPreviews(),
741
+ manifestWriter.getComponentDefinitions(),
742
+ )
743
+
744
+ // Finalize manifest (writes global manifest and waits for all per-page writes)
745
+ const stats = await manifestWriter.finalize()
746
+
747
+ const duration = Date.now() - startTime
748
+ const successCount = htmlFiles.length - errors.length
749
+ const msg =
750
+ `Processed ${successCount}/${htmlFiles.length} pages with ${stats.totalEntries} entries and ${stats.totalComponents} components in ${duration}ms`
751
+
752
+ if (logger) {
753
+ if (errors.length > 0) {
754
+ logger.warn(msg)
755
+ } else {
756
+ logger.info(msg)
757
+ }
758
+ } else {
759
+ console.log(`[cms] ${msg}`)
760
+ }
761
+ }
762
+
763
+ /**
764
+ * Recursively find all HTML files in a directory (parallel version)
765
+ */
766
+ async function findHtmlFiles(dir: string): Promise<string[]> {
767
+ const result: string[] = []
768
+
769
+ async function scan(currentDir: string): Promise<void> {
770
+ const entries = await fs.readdir(currentDir, { withFileTypes: true })
771
+
772
+ await Promise.all(entries.map(async (entry) => {
773
+ const fullPath = path.join(currentDir, entry.name)
774
+ if (entry.isDirectory()) {
775
+ await scan(fullPath)
776
+ } else if (entry.isFile() && fullPath.endsWith('.html')) {
777
+ result.push(fullPath)
778
+ }
779
+ }))
780
+ }
781
+
782
+ await scan(dir)
783
+ return result
784
+ }