@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,668 @@
1
+ import fs from 'node:fs/promises'
2
+ import path from 'node:path'
3
+
4
+ import { getProjectRoot } from '../config'
5
+ import type { Attribute, ManifestEntry } from '../types'
6
+ import { escapeRegex, generateSourceHash } from '../utils'
7
+ import { buildDefinitionPath } from './ast-extractors'
8
+ import { getCachedParsedFile } from './ast-parser'
9
+ import { findAttributeSourceLocation } from './cross-file-tracker'
10
+ import { findImageElementNearLine, findImageSourceLocation } from './image-finder'
11
+
12
+ // ============================================================================
13
+ // Text Normalization
14
+ // ============================================================================
15
+
16
+ /**
17
+ * Normalize text for comparison (handles escaping and entities)
18
+ */
19
+ export function normalizeText(text: string): string {
20
+ return text
21
+ .trim()
22
+ .replace(/\\'/g, "'") // Escaped single quotes
23
+ .replace(/\\"/g, '"') // Escaped double quotes
24
+ .replace(/'/g, "'") // HTML entity for apostrophe
25
+ .replace(/"/g, '"') // HTML entity for quote
26
+ .replace(/'/g, "'") // HTML entity for apostrophe (alternative)
27
+ .replace(/&/g, '&') // HTML entity for ampersand
28
+ .replace(/ /gi, ' ') // HTML entity for non-breaking space
29
+ .replace(/<br\s*\/?>/gi, '\n') // Normalize <br> tags to newlines
30
+ .replace(/<wbr\s*\/?>/gi, '') // Strip <wbr> tags (word break opportunity, no visible content)
31
+ .replace(/\s+/g, ' ') // Normalize whitespace
32
+ .toLowerCase()
33
+ }
34
+
35
+ /**
36
+ * Strip markdown syntax for text comparison
37
+ */
38
+ export function stripMarkdownSyntax(text: string): string {
39
+ return text
40
+ .replace(/^#+\s+/, '') // Headers
41
+ .replace(/\*\*([^*]+)\*\*/g, '$1') // Bold
42
+ .replace(/\*([^*]+)\*/g, '$1') // Italic
43
+ .replace(/__([^_]+)__/g, '$1') // Bold (underscore)
44
+ .replace(/_([^_]+)_/g, '$1') // Italic (underscore)
45
+ .replace(/`([^`]+)`/g, '$1') // Inline code
46
+ .replace(/\[([^\]]+)\]\([^)]+\)/g, '$1') // Links
47
+ .replace(/^\s*[-*+]\s+/, '') // List items
48
+ .replace(/^\s*\d+\.\s+/, '') // Numbered lists
49
+ .trim()
50
+ }
51
+
52
+ /**
53
+ * Find the 1-indexed line number where a text value is defined as a string literal.
54
+ * Searches for the text inside quote delimiters ("text", 'text', or `text`).
55
+ * Returns the line number, or undefined if not found.
56
+ */
57
+ export function findTextDefinitionLine(
58
+ content: string,
59
+ lines: string[],
60
+ text: string,
61
+ ): number | undefined {
62
+ // Search for the text inside string delimiters
63
+ for (const quote of ['"', "'", '`']) {
64
+ const searchStr = `${quote}${text}${quote}`
65
+ const idx = content.indexOf(searchStr)
66
+ if (idx !== -1) {
67
+ return content.substring(0, idx).split('\n').length
68
+ }
69
+ }
70
+
71
+ // Also try with common escape sequences (e.g., escaped quotes within the text)
72
+ const escapedForDouble = text.replace(/"/g, '\\"')
73
+ if (escapedForDouble !== text) {
74
+ const idx = content.indexOf(`"${escapedForDouble}"`)
75
+ if (idx !== -1) {
76
+ return content.substring(0, idx).split('\n').length
77
+ }
78
+ }
79
+
80
+ return undefined
81
+ }
82
+
83
+ // ============================================================================
84
+ // Snippet Extraction
85
+ // ============================================================================
86
+
87
+ /**
88
+ * Extract complete tag snippet including content and indentation.
89
+ * Exported for use in html-processor to populate sourceSnippet.
90
+ *
91
+ * When startLine points to a line inside the element (e.g., the text content line),
92
+ * this function searches backwards to find the opening tag first.
93
+ */
94
+ export function extractCompleteTagSnippet(lines: string[], startLine: number, tag: string): string {
95
+ // Pattern to match opening tag - either followed by whitespace/>, or at end of line (multi-line tag)
96
+ const escapedTag = escapeRegex(tag)
97
+ const openTagPattern = new RegExp(`<${escapedTag}(?:[\\s>]|$)`, 'gi')
98
+
99
+ // Check if the start line contains the opening tag
100
+ let actualStartLine = startLine
101
+ const startLineContent = lines[startLine] || ''
102
+ if (!openTagPattern.test(startLineContent)) {
103
+ // Search backwards to find the opening tag
104
+ for (let i = startLine - 1; i >= Math.max(0, startLine - 20); i--) {
105
+ const line = lines[i]
106
+ if (!line) continue
107
+
108
+ // Reset regex lastIndex for fresh test
109
+ openTagPattern.lastIndex = 0
110
+ if (openTagPattern.test(line)) {
111
+ actualStartLine = i
112
+ break
113
+ }
114
+ }
115
+ }
116
+
117
+ const snippetLines: string[] = []
118
+ let depth = 0
119
+ let foundClosing = false
120
+
121
+ // Start from the opening tag line
122
+ for (let i = actualStartLine; i < Math.min(actualStartLine + 30, lines.length); i++) {
123
+ const line = lines[i]
124
+
125
+ if (!line) {
126
+ continue
127
+ }
128
+
129
+ snippetLines.push(line)
130
+
131
+ // Count opening and closing tags
132
+ // Opening tag can be followed by whitespace, >, or end of line (multi-line tag)
133
+ const openTags = (line.match(new RegExp(`<${escapedTag}(?:[\\s>]|$)`, 'gi')) || []).length
134
+ const selfClosing = (line.match(new RegExp(`<${escapedTag}[^>]*/>`, 'gi')) || []).length
135
+ const closeTags = (line.match(new RegExp(`</${escapedTag}>`, 'gi')) || []).length
136
+
137
+ depth += openTags - selfClosing - closeTags
138
+
139
+ // If we found a self-closing tag or closed all tags, we're done
140
+ if (selfClosing > 0 || (depth <= 0 && (closeTags > 0 || openTags > 0))) {
141
+ foundClosing = true
142
+ break
143
+ }
144
+ }
145
+
146
+ // If we didn't find closing tag, just return the first line
147
+ if (!foundClosing && snippetLines.length > 1) {
148
+ return snippetLines[0]!
149
+ }
150
+
151
+ return snippetLines.join('\n')
152
+ }
153
+
154
+ /**
155
+ * Extract just the opening tag from source lines (e.g., `<a href="/foo" class="btn">`)
156
+ * Handles multi-line opening tags.
157
+ *
158
+ * @param lines - Source file lines
159
+ * @param startLine - 0-indexed line number where element starts
160
+ * @param tag - The tag name
161
+ * @returns The opening tag string, or undefined if can't extract
162
+ */
163
+ export function extractOpeningTagSnippet(lines: string[], startLine: number, tag: string): string | undefined {
164
+ const result = extractOpeningTagWithLine(lines, startLine, tag)
165
+ return result?.snippet
166
+ }
167
+
168
+ /**
169
+ * Extract the opening tag from source lines along with its starting line number.
170
+ * Handles multi-line opening tags.
171
+ *
172
+ * @param lines - Source file lines
173
+ * @param startLine - 0-indexed line number where element starts
174
+ * @param tag - The tag name
175
+ * @returns Object with the opening tag snippet and 0-indexed startLine, or undefined if can't extract
176
+ */
177
+ export function extractOpeningTagWithLine(
178
+ lines: string[],
179
+ startLine: number,
180
+ tag: string,
181
+ ): { snippet: string; startLine: number } | undefined {
182
+ const escapedTag = escapeRegex(tag)
183
+ const openTagPattern = new RegExp(`<${escapedTag}(?:[\\s>]|$)`, 'gi')
184
+
185
+ // Find the line containing the opening tag
186
+ let actualStartLine = startLine
187
+ const startLineContent = lines[startLine] || ''
188
+ if (!openTagPattern.test(startLineContent)) {
189
+ for (let i = startLine - 1; i >= Math.max(0, startLine - 20); i--) {
190
+ const line = lines[i]
191
+ if (!line) continue
192
+ openTagPattern.lastIndex = 0
193
+ if (openTagPattern.test(line)) {
194
+ actualStartLine = i
195
+ break
196
+ }
197
+ }
198
+ }
199
+
200
+ // Collect lines until we find the closing > of the opening tag
201
+ const snippetLines: string[] = []
202
+ for (let i = actualStartLine; i < Math.min(actualStartLine + 10, lines.length); i++) {
203
+ const line = lines[i]
204
+ if (!line) continue
205
+
206
+ snippetLines.push(line)
207
+ const combined = snippetLines.join('\n')
208
+
209
+ // Check if we have the complete opening tag (found the closing >)
210
+ // Match from <tag to the first > that's not part of => or />
211
+ const openTagMatch = combined.match(new RegExp(`<${escapedTag}[^>]*>`, 'i'))
212
+ if (openTagMatch) {
213
+ return { snippet: openTagMatch[0], startLine: actualStartLine }
214
+ }
215
+
216
+ // Also check for self-closing tag
217
+ const selfClosingMatch = combined.match(new RegExp(`<${escapedTag}[^>]*/\\s*>`, 'i'))
218
+ if (selfClosingMatch) {
219
+ return { snippet: selfClosingMatch[0], startLine: actualStartLine }
220
+ }
221
+ }
222
+
223
+ return undefined
224
+ }
225
+
226
+ /**
227
+ * Update attribute source information from an opening tag snippet.
228
+ * Determines whether each attribute is static (quoted value) or dynamic (expression).
229
+ * - For static attributes: sourcePath/Line/Snippet point to the template file
230
+ * - For dynamic attributes: sourcePath/Line/Snippet point to where the VALUE is defined
231
+ *
232
+ * @param openingTagSnippet - The opening tag string (e.g., `<a href={url} class="btn">`)
233
+ * @param attributes - Existing attributes with resolved values (isStatic will be updated)
234
+ * @param sourceFilePath - The source file path (used for static attrs and as starting point for dynamic attr tracing)
235
+ * @param openingTagStartLine - 1-indexed line number where the opening tag starts in the source file
236
+ * @returns Updated attributes with sourcePath, sourceLine, and sourceSnippet
237
+ */
238
+ export async function updateAttributeSources(
239
+ openingTagSnippet: string,
240
+ attributes: Record<string, Attribute>,
241
+ sourceFilePath?: string,
242
+ openingTagStartLine?: number,
243
+ sourceLines?: string[],
244
+ ): Promise<Record<string, Attribute>> {
245
+ const result: Record<string, Attribute> = {}
246
+
247
+ // Normalize the snippet (remove newlines, collapse whitespace for easier parsing)
248
+ const normalized = openingTagSnippet.replace(/\s+/g, ' ')
249
+
250
+ // Split opening tag into lines for finding attribute line numbers
251
+ const snippetLines = openingTagSnippet.split('\n')
252
+
253
+ // Process each attribute
254
+ const attrPromises = Object.entries(attributes).map(async ([attrName, attr]) => {
255
+ const { value } = attr
256
+
257
+ // Check for expression attribute: attr={expression} or attr={`template`}
258
+ const escapedAttrName = escapeRegex(attrName)
259
+ const exprPattern = new RegExp(`${escapedAttrName}\\s*=\\s*\\{([^}]+)\\}`, 'i')
260
+ const exprMatch = normalized.match(exprPattern)
261
+
262
+ if (exprMatch) {
263
+ const expression = exprMatch[1]!.trim()
264
+ const isTemplateLiteral = expression.startsWith('`') && expression.endsWith('`')
265
+ const cleanExpression = isTemplateLiteral ? expression.slice(1, -1) : expression
266
+
267
+ // For dynamic attributes, search by VALUE to find the source definition
268
+ if (sourceFilePath) {
269
+ const sourceLocation = await findAttributeSourceLocation(cleanExpression, value, sourceFilePath)
270
+ if (sourceLocation) {
271
+ return [attrName, {
272
+ value,
273
+ sourcePath: sourceLocation.file,
274
+ sourceLine: sourceLocation.line,
275
+ sourceSnippet: sourceLocation.snippet,
276
+ }] as const
277
+ }
278
+ }
279
+
280
+ // Couldn't resolve - return without source info
281
+ return [attrName, { value }] as const
282
+ }
283
+
284
+ // Check for static attribute: attr="value" or attr='value'
285
+ const staticPattern = new RegExp(`${escapedAttrName}\\s*=\\s*["']([^"']*)["']`, 'i')
286
+ const staticMatch = normalized.match(staticPattern)
287
+
288
+ if (staticMatch) {
289
+ const attrLine = findAttributeLineInSnippet(attrName, snippetLines, openingTagStartLine)
290
+
291
+ return [attrName, {
292
+ value,
293
+ sourcePath: sourceFilePath,
294
+ sourceLine: attrLine,
295
+ sourceSnippet: (attrLine && sourceLines) ? sourceLines[attrLine - 1] || '' : undefined,
296
+ }] as const
297
+ }
298
+
299
+ // Check for boolean attribute (just the attribute name, no value)
300
+ const boolPattern = new RegExp(`\\s${escapedAttrName}(?:\\s|>|/>)`, 'i')
301
+ if (boolPattern.test(normalized)) {
302
+ const attrLine = findAttributeLineInSnippet(attrName, snippetLines, openingTagStartLine)
303
+
304
+ return [attrName, {
305
+ value,
306
+ sourcePath: sourceFilePath,
307
+ sourceLine: attrLine,
308
+ sourceSnippet: (attrLine && sourceLines) ? sourceLines[attrLine - 1] || '' : undefined,
309
+ }] as const
310
+ }
311
+
312
+ // Fallback: couldn't determine source type, keep original
313
+ return [attrName, attr] as const
314
+ })
315
+
316
+ const results = await Promise.all(attrPromises)
317
+ for (const [attrName, attrValue] of results) {
318
+ result[attrName] = attrValue
319
+ }
320
+
321
+ return result
322
+ }
323
+
324
+ /**
325
+ * Find the 1-indexed line number of an attribute within an opening tag snippet.
326
+ */
327
+ function findAttributeLineInSnippet(
328
+ attrName: string,
329
+ snippetLines: string[],
330
+ startLine?: number,
331
+ ): number | undefined {
332
+ if (!startLine) return undefined
333
+ const attrPattern = new RegExp(`(?:^|\\s)${escapeRegex(attrName)}(?:\\s*=|\\s|>|/>|$)`, 'i')
334
+ for (let i = 0; i < snippetLines.length; i++) {
335
+ if (attrPattern.test(snippetLines[i]!)) {
336
+ return startLine + i
337
+ }
338
+ }
339
+ return undefined
340
+ }
341
+
342
+ /**
343
+ * Update colorClasses entries with source info from the class attribute in the opening tag.
344
+ * All color classes come from the same `class="..."` attribute, so they share the same source location.
345
+ */
346
+ export function updateColorClassSources(
347
+ openingTagSnippet: string,
348
+ colorClasses: Record<string, Attribute>,
349
+ sourceFilePath?: string,
350
+ openingTagStartLine?: number,
351
+ sourceLines?: string[],
352
+ ): Record<string, Attribute> {
353
+ const snippetLines = openingTagSnippet.split('\n')
354
+ const classLine = findAttributeLineInSnippet('class', snippetLines, openingTagStartLine)
355
+ const sourceSnippet = (classLine && sourceLines) ? sourceLines[classLine - 1] || '' : undefined
356
+
357
+ const result: Record<string, Attribute> = {}
358
+ for (const [key, attr] of Object.entries(colorClasses)) {
359
+ result[key] = {
360
+ ...attr,
361
+ sourcePath: sourceFilePath,
362
+ sourceLine: classLine,
363
+ sourceSnippet,
364
+ }
365
+ }
366
+ return result
367
+ }
368
+
369
+ /**
370
+ * Extract innerHTML from a complete tag snippet.
371
+ * Given `<p class="foo">content here</p>`, returns `content here`.
372
+ *
373
+ * @param snippet - The complete tag snippet from source
374
+ * @param tag - The tag name (e.g., 'p', 'h1')
375
+ * @returns The innerHTML portion, or undefined if can't extract
376
+ */
377
+ export function extractInnerHtmlFromSnippet(snippet: string, tag: string): string | undefined {
378
+ // Match opening tag (with any attributes) and extract content until closing tag
379
+ // Handle both single-line and multi-line cases
380
+ const escapedTag = escapeRegex(tag)
381
+ const openTagPattern = new RegExp(`<${escapedTag}(?:\\s[^>]*)?>`, 'i')
382
+ const closeTagPattern = new RegExp(`</${escapedTag}>`, 'i')
383
+
384
+ const openMatch = snippet.match(openTagPattern)
385
+ if (!openMatch) return undefined
386
+
387
+ const openTagEnd = openMatch.index! + openMatch[0].length
388
+ const closeMatch = snippet.match(closeTagPattern)
389
+ if (!closeMatch) return undefined
390
+
391
+ const closeTagStart = closeMatch.index!
392
+
393
+ // Extract content between opening and closing tags
394
+ if (closeTagStart > openTagEnd) {
395
+ return snippet.substring(openTagEnd, closeTagStart)
396
+ }
397
+
398
+ return undefined
399
+ }
400
+
401
+ /**
402
+ * Extract the full <img> tag snippet from source lines
403
+ */
404
+ export function extractImageSnippet(lines: string[], startLine: number): string {
405
+ const snippetLines: string[] = []
406
+ let foundClosing = false
407
+
408
+ for (let i = startLine; i < Math.min(startLine + 10, lines.length); i++) {
409
+ const line = lines[i]
410
+ if (!line) continue
411
+
412
+ snippetLines.push(line)
413
+
414
+ // Check if this line contains the closing of the img tag
415
+ // img tags can be self-closing /> or just >
416
+ if (line.includes('/>') || (line.includes('<img') && line.includes('>'))) {
417
+ foundClosing = true
418
+ break
419
+ }
420
+ }
421
+
422
+ if (!foundClosing && snippetLines.length > 1) {
423
+ return snippetLines[0]!
424
+ }
425
+
426
+ return snippetLines.join('\n')
427
+ }
428
+
429
+ /**
430
+ * Read source file and extract the complete element at the specified line.
431
+ *
432
+ * @param sourceFile - Path to source file (relative to cwd)
433
+ * @param sourceLine - 1-indexed line number
434
+ * @param tag - The tag name
435
+ * @returns The complete element from source, or undefined if can't extract
436
+ */
437
+ export async function extractSourceSnippet(
438
+ sourceFile: string,
439
+ sourceLine: number,
440
+ tag: string,
441
+ ): Promise<string | undefined> {
442
+ try {
443
+ const filePath = path.isAbsolute(sourceFile)
444
+ ? sourceFile
445
+ : path.join(getProjectRoot(), sourceFile)
446
+
447
+ const content = await fs.readFile(filePath, 'utf-8')
448
+ const lines = content.split('\n')
449
+
450
+ // Extract the complete tag snippet (including wrapper element)
451
+ return extractCompleteTagSnippet(lines, sourceLine - 1, tag)
452
+ } catch {
453
+ return undefined
454
+ }
455
+ }
456
+
457
+ // ============================================================================
458
+ // Manifest Enhancement
459
+ // ============================================================================
460
+
461
+ /**
462
+ * Enhance manifest entries with actual source snippets from source files.
463
+ * This reads the source files and extracts the innerHTML at the specified locations.
464
+ * For images, it finds the correct line containing the src attribute.
465
+ *
466
+ * @param entries - Manifest entries to enhance
467
+ * @returns Enhanced entries with sourceSnippet and openingTagSnippet populated
468
+ */
469
+ export async function enhanceManifestWithSourceSnippets(
470
+ entries: Record<string, ManifestEntry>,
471
+ ): Promise<Record<string, ManifestEntry>> {
472
+ const enhanced: Record<string, ManifestEntry> = {}
473
+
474
+ // Process entries in parallel for better performance
475
+ const entryPromises = Object.entries(entries).map(async ([id, entry]) => {
476
+ // Handle image entries specially - find the line with src attribute
477
+ if (entry.imageMetadata?.src) {
478
+ const imageLocation = await findImageSourceLocation(entry.imageMetadata.src, entry.imageMetadata.srcSet)
479
+ if (imageLocation) {
480
+ const sourceHash = generateSourceHash(imageLocation.snippet || entry.imageMetadata.src)
481
+ const updated: ManifestEntry = {
482
+ ...entry,
483
+ sourcePath: imageLocation.file,
484
+ sourceLine: imageLocation.line,
485
+ sourceSnippet: imageLocation.snippet,
486
+ sourceHash,
487
+ }
488
+
489
+ // Also update attribute and colorClasses source info from the opening tag
490
+ try {
491
+ const filePath = path.isAbsolute(imageLocation.file)
492
+ ? imageLocation.file
493
+ : path.join(getProjectRoot(), imageLocation.file)
494
+ const content = await fs.readFile(filePath, 'utf-8')
495
+ const lines = content.split('\n')
496
+ const openingTagInfo = extractOpeningTagWithLine(lines, imageLocation.line - 1, entry.tag)
497
+
498
+ if (openingTagInfo) {
499
+ const startLine = openingTagInfo.startLine + 1
500
+ if (updated.attributes) {
501
+ updated.attributes = await updateAttributeSources(
502
+ openingTagInfo.snippet,
503
+ updated.attributes,
504
+ imageLocation.file,
505
+ startLine,
506
+ lines,
507
+ )
508
+ }
509
+ if (updated.colorClasses) {
510
+ updated.colorClasses = updateColorClassSources(
511
+ openingTagInfo.snippet,
512
+ updated.colorClasses,
513
+ imageLocation.file,
514
+ startLine,
515
+ lines,
516
+ )
517
+ }
518
+ }
519
+ } catch {
520
+ // Couldn't read file - return without source lines on attributes
521
+ }
522
+
523
+ return [id, updated] as const
524
+ }
525
+
526
+ // Fallback for expression-based src attributes (src={variable})
527
+ // Use the entry's existing sourcePath/sourceLine to find the img tag
528
+ // by its position in the AST rather than by src value
529
+ if (entry.sourcePath && entry.sourceLine) {
530
+ try {
531
+ const filePath = path.isAbsolute(entry.sourcePath)
532
+ ? entry.sourcePath
533
+ : path.join(getProjectRoot(), entry.sourcePath)
534
+ const cached = await getCachedParsedFile(filePath)
535
+ if (cached) {
536
+ const nearbyImg = findImageElementNearLine(cached.ast, entry.sourceLine, cached.lines)
537
+ if (nearbyImg) {
538
+ const sourceHash = generateSourceHash(nearbyImg.snippet || entry.imageMetadata.src)
539
+ return [id, {
540
+ ...entry,
541
+ sourceLine: nearbyImg.line,
542
+ sourceSnippet: nearbyImg.snippet,
543
+ sourceHash,
544
+ }] as const
545
+ }
546
+ }
547
+ } catch {
548
+ // Fallback search failed
549
+ }
550
+ }
551
+
552
+ return [id, entry] as const
553
+ }
554
+
555
+ // Skip if already has sourceSnippet or missing source info
556
+ if (entry.sourceSnippet || !entry.sourcePath || !entry.sourceLine || !entry.tag) {
557
+ return [id, entry] as const
558
+ }
559
+
560
+ // Read file once and extract both snippets
561
+ try {
562
+ const filePath = path.isAbsolute(entry.sourcePath)
563
+ ? entry.sourcePath
564
+ : path.join(getProjectRoot(), entry.sourcePath)
565
+
566
+ const content = await fs.readFile(filePath, 'utf-8')
567
+ const lines = content.split('\n')
568
+
569
+ // Extract the complete source element
570
+ const sourceSnippet = extractCompleteTagSnippet(lines, entry.sourceLine - 1, entry.tag)
571
+
572
+ // Extract opening tag with its start line for attribute line tracking
573
+ const openingTagInfo = extractOpeningTagWithLine(lines, entry.sourceLine - 1, entry.tag)
574
+
575
+ // Update attribute sources if we have an opening tag and attributes
576
+ // - Static attributes get sourceLine/snippet from the template
577
+ // - Dynamic attributes get traced to their actual value definition
578
+ let attributes = entry.attributes
579
+ if (openingTagInfo && attributes) {
580
+ attributes = await updateAttributeSources(
581
+ openingTagInfo.snippet,
582
+ attributes,
583
+ entry.sourcePath,
584
+ openingTagInfo.startLine + 1, // Convert to 1-indexed
585
+ lines,
586
+ )
587
+ }
588
+
589
+ // Update colorClasses with source info from the class attribute
590
+ let colorClasses = entry.colorClasses
591
+ if (openingTagInfo && colorClasses) {
592
+ colorClasses = updateColorClassSources(
593
+ openingTagInfo.snippet,
594
+ colorClasses,
595
+ entry.sourcePath,
596
+ openingTagInfo.startLine + 1, // Convert to 1-indexed
597
+ lines,
598
+ )
599
+ }
600
+
601
+ if (sourceSnippet) {
602
+ const trimmedText = entry.text?.trim()
603
+
604
+ // Check if text is directly in the snippet (static content)
605
+ if (trimmedText && !sourceSnippet.includes(trimmedText)) {
606
+ // Text from dynamic expression — resolve via variable definitions
607
+ const cached = await getCachedParsedFile(filePath)
608
+ if (cached) {
609
+ const normalizedSearch = normalizeText(entry.text!)
610
+ const matchingDef = cached.variableDefinitions.find(
611
+ def => normalizeText(def.value) === normalizedSearch,
612
+ )
613
+ if (matchingDef) {
614
+ const defSnippet = lines[matchingDef.line - 1] || ''
615
+ const sourceHash = generateSourceHash(defSnippet)
616
+ return [id, {
617
+ ...entry,
618
+ sourceLine: matchingDef.line,
619
+ sourceSnippet: defSnippet,
620
+ variableName: buildDefinitionPath(matchingDef),
621
+ attributes,
622
+ colorClasses,
623
+ sourceHash,
624
+ }] as const
625
+ }
626
+ }
627
+
628
+ // Fallback: search for the literal text in file content
629
+ // This handles cases where AST-based lookup fails (e.g., concurrent parsing)
630
+ const foundLine = findTextDefinitionLine(content, lines, trimmedText)
631
+ if (foundLine) {
632
+ const defSnippet = lines[foundLine - 1] || ''
633
+ const sourceHash = generateSourceHash(defSnippet)
634
+ return [id, {
635
+ ...entry,
636
+ sourceLine: foundLine,
637
+ sourceSnippet: defSnippet,
638
+ attributes,
639
+ colorClasses,
640
+ sourceHash,
641
+ }] as const
642
+ }
643
+ }
644
+
645
+ // Original static content path
646
+ const sourceHash = generateSourceHash(sourceSnippet)
647
+ return [id, {
648
+ ...entry,
649
+ sourceSnippet,
650
+ attributes,
651
+ colorClasses,
652
+ sourceHash,
653
+ }] as const
654
+ }
655
+ } catch {
656
+ // Fall through to return entry as-is
657
+ }
658
+
659
+ return [id, entry] as const
660
+ })
661
+
662
+ const results = await Promise.all(entryPromises)
663
+ for (const [id, entry] of results) {
664
+ enhanced[id] = entry
665
+ }
666
+
667
+ return enhanced
668
+ }