@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,304 @@
1
+ import fs from 'node:fs/promises'
2
+ import path from 'node:path'
3
+ import { parse as parseYaml } from 'yaml'
4
+ import { getProjectRoot } from './config'
5
+ import type { CollectionDefinition, CollectionEntryInfo, FieldDefinition, FieldType } from './types'
6
+
7
+ /** Regex patterns for type inference */
8
+ const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}/
9
+ const URL_PATTERN = /^(https?:\/\/|\/)/
10
+ const IMAGE_EXTENSIONS = /\.(jpg|jpeg|png|gif|webp|svg|avif)$/i
11
+
12
+ /** Maximum unique values before treating as free-form text instead of select */
13
+ const MAX_SELECT_OPTIONS = 10
14
+
15
+ /** Minimum length for textarea detection */
16
+ const TEXTAREA_MIN_LENGTH = 200
17
+
18
+ /** Field names that should never be inferred as select (always free-text) */
19
+ const FREE_TEXT_FIELD_NAMES = new Set([
20
+ 'title',
21
+ 'name',
22
+ 'description',
23
+ 'summary',
24
+ 'excerpt',
25
+ 'subtitle',
26
+ 'heading',
27
+ 'headline',
28
+ 'slug',
29
+ 'alt',
30
+ 'caption',
31
+ ])
32
+
33
+ /**
34
+ * Observed values for a single field across multiple files
35
+ */
36
+ interface FieldObservation {
37
+ name: string
38
+ values: unknown[]
39
+ presentCount: number
40
+ totalEntries: number
41
+ }
42
+
43
+ /**
44
+ * Parse YAML frontmatter from markdown content
45
+ */
46
+ function parseFrontmatter(content: string): Record<string, unknown> | null {
47
+ const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/)
48
+ if (!match?.[1]) return null
49
+
50
+ return parseYaml(match[1]) as Record<string, unknown> | null
51
+ }
52
+
53
+ /**
54
+ * Infer the field type from a value
55
+ */
56
+ function inferFieldType(value: unknown, key: string): FieldType {
57
+ if (value === null || value === undefined) {
58
+ return 'text'
59
+ }
60
+
61
+ if (typeof value === 'boolean') {
62
+ return 'boolean'
63
+ }
64
+
65
+ if (typeof value === 'number') {
66
+ return 'number'
67
+ }
68
+
69
+ if (Array.isArray(value)) {
70
+ return 'array'
71
+ }
72
+
73
+ if (typeof value === 'object') {
74
+ return 'object'
75
+ }
76
+
77
+ if (typeof value === 'string') {
78
+ // Check for date pattern
79
+ if (DATE_PATTERN.test(value)) {
80
+ return 'date'
81
+ }
82
+
83
+ // Check for image paths
84
+ if (IMAGE_EXTENSIONS.test(value)) {
85
+ return 'image'
86
+ }
87
+
88
+ // Check for image-specific field names (exact word boundaries, not substrings)
89
+ const lowerKey = key.toLowerCase()
90
+ if (/(?:^|[_-])(?:image|thumbnail|cover|avatar|logo|icon|banner|photo)(?:$|[_-])/.test(lowerKey)) {
91
+ return 'image'
92
+ }
93
+
94
+ // Check for URLs
95
+ if (URL_PATTERN.test(value)) {
96
+ return 'url'
97
+ }
98
+
99
+ // Check for textarea (long text or contains newlines)
100
+ if (value.includes('\n') || value.length > TEXTAREA_MIN_LENGTH) {
101
+ return 'textarea'
102
+ }
103
+
104
+ return 'text'
105
+ }
106
+
107
+ return 'text'
108
+ }
109
+
110
+ /**
111
+ * Merge field observations from multiple files to determine final field definition
112
+ */
113
+ function mergeFieldObservations(observations: FieldObservation[]): FieldDefinition[] {
114
+ const fields: FieldDefinition[] = []
115
+
116
+ for (const obs of observations) {
117
+ const nonNullValues = obs.values.filter(v => v !== null && v !== undefined)
118
+ if (nonNullValues.length === 0) continue
119
+
120
+ // Determine type by consensus (most common inferred type)
121
+ const typeCounts = new Map<FieldType, number>()
122
+ for (const value of nonNullValues) {
123
+ const type = inferFieldType(value, obs.name)
124
+ typeCounts.set(type, (typeCounts.get(type) || 0) + 1)
125
+ }
126
+
127
+ // Get most common type
128
+ let fieldType: FieldType = 'text'
129
+ let maxCount = 0
130
+ for (const [type, count] of typeCounts) {
131
+ if (count > maxCount) {
132
+ maxCount = count
133
+ fieldType = type
134
+ }
135
+ }
136
+
137
+ const field: FieldDefinition = {
138
+ name: obs.name,
139
+ type: fieldType,
140
+ required: obs.presentCount === obs.totalEntries,
141
+ examples: nonNullValues.slice(0, 3),
142
+ }
143
+
144
+ // For text fields, check if we should treat as select (limited unique values)
145
+ if (fieldType === 'text' && !FREE_TEXT_FIELD_NAMES.has(obs.name.toLowerCase())) {
146
+ const uniqueValues = [...new Set(nonNullValues.map(v => String(v)))]
147
+ const uniqueRatio = uniqueValues.length / nonNullValues.length
148
+ // Only treat as select if unique values are limited AND not nearly all unique
149
+ // (a high unique ratio means entries have distinct values, indicating free-text)
150
+ if (uniqueValues.length > 0 && uniqueValues.length <= MAX_SELECT_OPTIONS && nonNullValues.length >= 2 && uniqueRatio <= 0.8) {
151
+ field.type = 'select'
152
+ field.options = uniqueValues.sort()
153
+ }
154
+ }
155
+
156
+ // For arrays, try to infer item type
157
+ if (fieldType === 'array') {
158
+ const allItems = nonNullValues.flatMap(v => (Array.isArray(v) ? v : []))
159
+ if (allItems.length > 0) {
160
+ const itemType = inferFieldType(allItems[0], obs.name)
161
+ field.itemType = itemType
162
+
163
+ // Check if array items should be select
164
+ if (itemType === 'text') {
165
+ const uniqueItems = [...new Set(allItems.map(v => String(v)))]
166
+ if (uniqueItems.length <= MAX_SELECT_OPTIONS * 2) {
167
+ field.options = uniqueItems.sort()
168
+ }
169
+ }
170
+ }
171
+ }
172
+
173
+ fields.push(field)
174
+ }
175
+
176
+ return fields
177
+ }
178
+
179
+ /**
180
+ * Scan a single collection directory and infer its schema
181
+ */
182
+ async function scanCollection(collectionPath: string, collectionName: string, contentDir: string): Promise<CollectionDefinition | null> {
183
+ try {
184
+ const entries = await fs.readdir(collectionPath, { withFileTypes: true })
185
+ const markdownFiles = entries.filter(e => e.isFile() && (e.name.endsWith('.md') || e.name.endsWith('.mdx')))
186
+
187
+ if (markdownFiles.length === 0) return null
188
+
189
+ // Determine file extension (prefer md, use mdx if that's all we have)
190
+ const hasMd = markdownFiles.some(f => f.name.endsWith('.md'))
191
+ const fileExtension: 'md' | 'mdx' = hasMd ? 'md' : 'mdx'
192
+
193
+ // Collect field observations and entry info across all files
194
+ const fieldMap = new Map<string, FieldObservation>()
195
+ const entryInfos: CollectionEntryInfo[] = []
196
+ let hasDraft = false
197
+
198
+ for (const file of markdownFiles) {
199
+ const filePath = path.join(collectionPath, file.name)
200
+ const content = await fs.readFile(filePath, 'utf-8')
201
+ const frontmatter = parseFrontmatter(content)
202
+
203
+ // Collect entry info
204
+ const slug = file.name.replace(/\.(md|mdx)$/, '')
205
+ const entryInfo: CollectionEntryInfo = {
206
+ slug,
207
+ sourcePath: path.join(contentDir, collectionName, file.name),
208
+ }
209
+ if (frontmatter) {
210
+ if (typeof frontmatter.title === 'string') {
211
+ entryInfo.title = frontmatter.title
212
+ }
213
+ if (typeof frontmatter.draft === 'boolean' && frontmatter.draft) {
214
+ entryInfo.draft = true
215
+ }
216
+ }
217
+ entryInfos.push(entryInfo)
218
+
219
+ if (!frontmatter) continue
220
+
221
+ for (const [key, value] of Object.entries(frontmatter)) {
222
+ if (key === 'draft' && typeof value === 'boolean') {
223
+ hasDraft = true
224
+ }
225
+
226
+ let obs = fieldMap.get(key)
227
+ if (!obs) {
228
+ obs = {
229
+ name: key,
230
+ values: [],
231
+ presentCount: 0,
232
+ totalEntries: markdownFiles.length,
233
+ }
234
+ fieldMap.set(key, obs)
235
+ }
236
+
237
+ obs.values.push(value)
238
+ obs.presentCount++
239
+ }
240
+ }
241
+
242
+ // Sort entries alphabetically by title (fallback to slug)
243
+ entryInfos.sort((a, b) => {
244
+ const aLabel = a.title ?? a.slug
245
+ const bLabel = b.title ?? b.slug
246
+ return aLabel.localeCompare(bLabel)
247
+ })
248
+
249
+ // Update totalEntries for all observations
250
+ for (const obs of fieldMap.values()) {
251
+ obs.totalEntries = markdownFiles.length
252
+ }
253
+
254
+ const fields = mergeFieldObservations(Array.from(fieldMap.values()))
255
+
256
+ // Generate a human-readable label
257
+ const label = collectionName
258
+ .replace(/[-_]/g, ' ')
259
+ .replace(/\b\w/g, c => c.toUpperCase())
260
+
261
+ return {
262
+ name: collectionName,
263
+ label,
264
+ path: path.join(contentDir, collectionName),
265
+ entryCount: markdownFiles.length,
266
+ fields,
267
+ supportsDraft: hasDraft,
268
+ fileExtension,
269
+ entries: entryInfos,
270
+ }
271
+ } catch {
272
+ return null
273
+ }
274
+ }
275
+
276
+ /**
277
+ * Scan all collections in the content directory
278
+ */
279
+ export async function scanCollections(contentDir: string = 'src/content'): Promise<Record<string, CollectionDefinition>> {
280
+ const projectRoot = getProjectRoot()
281
+ const fullContentDir = path.isAbsolute(contentDir) ? contentDir : path.join(projectRoot, contentDir)
282
+
283
+ const collections: Record<string, CollectionDefinition> = {}
284
+
285
+ try {
286
+ const entries = await fs.readdir(fullContentDir, { withFileTypes: true })
287
+
288
+ const scanPromises = entries
289
+ .filter(entry => entry.isDirectory() && !entry.name.startsWith('_') && !entry.name.startsWith('.'))
290
+ .map(async entry => {
291
+ const collectionPath = path.join(fullContentDir, entry.name)
292
+ const definition = await scanCollection(collectionPath, entry.name, contentDir)
293
+ if (definition) {
294
+ collections[entry.name] = definition
295
+ }
296
+ })
297
+
298
+ await Promise.all(scanPromises)
299
+ } catch {
300
+ // Content directory doesn't exist or isn't readable
301
+ }
302
+
303
+ return collections
304
+ }
@@ -0,0 +1,393 @@
1
+ import fs from 'node:fs/promises'
2
+ import path from 'node:path'
3
+ import { getProjectRoot } from './config'
4
+ import { getErrorCollector } from './error-collector'
5
+ import type { ComponentDefinition, ComponentProp } from './types'
6
+
7
+ /**
8
+ * Scans Astro component files and extracts their definitions including props
9
+ */
10
+ export class ComponentRegistry {
11
+ private components: Map<string, ComponentDefinition> = new Map()
12
+ private componentDirs: string[]
13
+
14
+ constructor(componentDirs: string[] = ['src/components']) {
15
+ this.componentDirs = componentDirs
16
+ }
17
+
18
+ /**
19
+ * Scan all component directories and build the registry
20
+ */
21
+ async scan(): Promise<void> {
22
+ for (const dir of this.componentDirs) {
23
+ const fullPath = path.join(getProjectRoot(), dir)
24
+ try {
25
+ await this.scanDirectory(fullPath, dir)
26
+ } catch {
27
+ // Directory doesn't exist, skip
28
+ }
29
+ }
30
+ }
31
+
32
+ /**
33
+ * Get all registered components
34
+ */
35
+ getComponents(): Record<string, ComponentDefinition> {
36
+ return Object.fromEntries(this.components)
37
+ }
38
+
39
+ /**
40
+ * Get a specific component by name
41
+ */
42
+ getComponent(name: string): ComponentDefinition | undefined {
43
+ return this.components.get(name)
44
+ }
45
+
46
+ /**
47
+ * Scan a directory recursively for .astro files
48
+ */
49
+ private async scanDirectory(dir: string, relativePath: string): Promise<void> {
50
+ const entries = await fs.readdir(dir, { withFileTypes: true })
51
+
52
+ for (const entry of entries) {
53
+ const fullPath = path.join(dir, entry.name)
54
+ const relPath = path.join(relativePath, entry.name)
55
+
56
+ if (entry.isDirectory()) {
57
+ await this.scanDirectory(fullPath, relPath)
58
+ } else if (entry.isFile() && entry.name.endsWith('.astro')) {
59
+ await this.parseComponent(fullPath, relPath)
60
+ }
61
+ }
62
+ }
63
+
64
+ /**
65
+ * Parse a single Astro component file
66
+ */
67
+ private async parseComponent(filePath: string, relativePath: string): Promise<void> {
68
+ try {
69
+ const content = await fs.readFile(filePath, 'utf-8')
70
+ const componentName = path.basename(filePath, '.astro')
71
+
72
+ const props = await this.extractProps(content)
73
+ const slots = this.extractSlots(content)
74
+ const description = this.extractDescription(content)
75
+ const previewWidth = this.extractPreviewWidth(content)
76
+
77
+ this.components.set(componentName, {
78
+ name: componentName,
79
+ file: relativePath,
80
+ props,
81
+ slots: slots.length > 0 ? slots : undefined,
82
+ description,
83
+ previewWidth,
84
+ })
85
+ } catch (error) {
86
+ console.warn(`[ComponentRegistry] Failed to parse ${filePath}:`, error)
87
+ }
88
+ }
89
+
90
+ /**
91
+ * Parse Props content and extract individual property definitions
92
+ * Handles multi-line properties with nested types
93
+ */
94
+ private parsePropsContent(propsContent: string): ComponentProp[] {
95
+ const props: ComponentProp[] = []
96
+ let i = 0
97
+ const content = propsContent.trim()
98
+
99
+ while (i < content.length) {
100
+ // Skip whitespace and newlines
101
+ while (i < content.length && /\s/.test(content[i] ?? '')) i++
102
+ if (i >= content.length) break
103
+
104
+ // Skip comments
105
+ if (content[i] === '/' && content[i + 1] === '/') {
106
+ // Skip to end of line
107
+ while (i < content.length && content[i] !== '\n') i++
108
+ continue
109
+ }
110
+
111
+ if (content[i] === '/' && content[i + 1] === '*') {
112
+ // Skip block comment
113
+ while (i < content.length - 1 && !(content[i] === '*' && content[i + 1] === '/')) i++
114
+ i += 2
115
+ continue
116
+ }
117
+
118
+ // Extract property name
119
+ const nameStart = i
120
+ while (i < content.length && /\w/.test(content[i] ?? '')) i++
121
+ const name = content.substring(nameStart, i)
122
+
123
+ if (!name) break
124
+
125
+ // Skip whitespace
126
+ while (i < content.length && /\s/.test(content[i] ?? '')) i++
127
+
128
+ // Check for optional marker
129
+ const optional = content[i] === '?'
130
+ if (optional) i++
131
+
132
+ // Skip whitespace
133
+ while (i < content.length && /\s/.test(content[i] ?? '')) i++
134
+
135
+ // Expect colon
136
+ if (content[i] !== ':') break
137
+ i++
138
+
139
+ // Skip whitespace
140
+ while (i < content.length && /\s/.test(content[i] ?? '')) i++
141
+
142
+ // Extract type (up to semicolon, handling nested braces)
143
+ const typeStart = i
144
+ let braceDepth = 0
145
+ let angleDepth = 0
146
+ while (i < content.length) {
147
+ if (content[i] === '{') braceDepth++
148
+ else if (content[i] === '}') braceDepth--
149
+ else if (content[i] === '<') angleDepth++
150
+ else if (content[i] === '>') angleDepth--
151
+ else if (content[i] === ';' && braceDepth === 0 && angleDepth === 0) break
152
+ i++
153
+ }
154
+
155
+ const type = content.substring(typeStart, i).trim()
156
+
157
+ // Skip the semicolon
158
+ if (content[i] === ';') i++
159
+
160
+ // Skip whitespace
161
+ while (i < content.length && /[ \t]/.test(content[i] ?? '')) i++
162
+
163
+ // Check for inline comment
164
+ let description: string | undefined
165
+ if (content[i] === '/' && content[i + 1] === '/') {
166
+ i += 2
167
+ const commentStart = i
168
+ while (i < content.length && content[i] !== '\n') i++
169
+ description = content.substring(commentStart, i).trim()
170
+ }
171
+
172
+ if (name && type) {
173
+ props.push({
174
+ name,
175
+ type,
176
+ required: !optional,
177
+ description,
178
+ })
179
+ }
180
+ }
181
+
182
+ return props
183
+ }
184
+
185
+ /**
186
+ * Extract content between balanced braces after a pattern match
187
+ * Properly handles nested objects
188
+ */
189
+ private extractBalancedBraces(text: string, pattern: RegExp): string | null {
190
+ const match = text.match(pattern)
191
+ if (!match || match.index === undefined) return null
192
+
193
+ // Find the opening brace position (right after the match)
194
+ const startIndex = match.index + match[0].length
195
+ let depth = 1 // We already have one opening brace
196
+ let i = startIndex
197
+
198
+ // Find the matching closing brace
199
+ while (i < text.length && depth > 0) {
200
+ if (text[i] === '{') {
201
+ depth++
202
+ } else if (text[i] === '}') {
203
+ depth--
204
+ }
205
+ i++
206
+ }
207
+
208
+ if (depth !== 0) return null // Unbalanced braces
209
+
210
+ // Extract content between braces (excluding the braces themselves)
211
+ return text.substring(startIndex, i - 1)
212
+ }
213
+
214
+ /**
215
+ * Extract props from component frontmatter
216
+ */
217
+ private async extractProps(content: string): Promise<ComponentProp[]> {
218
+ const props: ComponentProp[] = []
219
+
220
+ // Find the frontmatter section
221
+ const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---/)
222
+ if (!frontmatterMatch?.[1]) return props
223
+
224
+ const frontmatter = frontmatterMatch[1]
225
+
226
+ // Look for Props interface
227
+ const propsInterfaceContent = this.extractBalancedBraces(frontmatter, /interface\s+Props\s*\{/)
228
+ if (propsInterfaceContent) {
229
+ const extractedProps = this.parsePropsContent(propsInterfaceContent)
230
+ props.push(...extractedProps)
231
+ }
232
+
233
+ // Look for type Props = { ... }
234
+ if (props.length === 0) {
235
+ const typePropsContent = this.extractBalancedBraces(frontmatter, /type\s+Props\s*=\s*\{/)
236
+ if (typePropsContent) {
237
+ const extractedProps = this.parsePropsContent(typePropsContent)
238
+ props.push(...extractedProps)
239
+ }
240
+ }
241
+
242
+ const destructureMatch = frontmatter?.match(/const\s*\{([^}]+)\}\s*=\s*Astro\.props/)
243
+ if (destructureMatch) {
244
+ const destructureContent = destructureMatch[1]
245
+
246
+ const defaultMatches = destructureContent?.matchAll(/(\w+)\s*=\s*(['"`]?)([^'"`},]+)\2/g) ?? []
247
+ for (const match of defaultMatches) {
248
+ const propName = match[1]
249
+ const defaultValue = match[3]
250
+ const existingProp = props.find(p => p.name === propName)
251
+ if (existingProp) {
252
+ existingProp.defaultValue = defaultValue
253
+ }
254
+ }
255
+ }
256
+
257
+ return props
258
+ }
259
+
260
+ /**
261
+ * Extract slot names from template
262
+ */
263
+ private extractSlots(content: string): string[] {
264
+ const slots: string[] = []
265
+
266
+ // Find <slot> elements with name attribute
267
+ const slotMatches = content.matchAll(/<slot\s+name=["']([^"']+)["']/g)
268
+ for (const match of slotMatches) {
269
+ if (match[1]) {
270
+ slots.push(match[1])
271
+ }
272
+ }
273
+
274
+ // Check for default slot (unnamed slot) - match any <slot> tag without a name attribute
275
+ const allSlotTags = content.matchAll(/<slot(?:\s+[^>]*)?\s*\/?>/g)
276
+ for (const match of allSlotTags) {
277
+ const tag = match[0]
278
+ // Check if this slot tag doesn't have a name attribute
279
+ if (!/name\s*=/.test(tag)) {
280
+ if (!slots.includes('default')) {
281
+ slots.unshift('default')
282
+ }
283
+ break // Only need to find one default slot
284
+ }
285
+ }
286
+
287
+ return slots
288
+ }
289
+
290
+ /**
291
+ * Extract component description from JSDoc comment
292
+ */
293
+ private extractDescription(content: string): string | undefined {
294
+ // Look for JSDoc comment at the start of frontmatter
295
+ const match = content.match(/^---\n\/\*\*\s*([\s\S]*?)\s*\*\//)
296
+ if (match?.[1]) {
297
+ return match[1]
298
+ .split('\n')
299
+ .map(line => line.replace(/^\s*\*\s?/, '').trim())
300
+ .filter(Boolean)
301
+ .join(' ')
302
+ }
303
+ return undefined
304
+ }
305
+
306
+ /**
307
+ * Extract @previewWidth value from JSDoc comment
308
+ */
309
+ private extractPreviewWidth(content: string): number | undefined {
310
+ const match = content.match(/^---\n\/\*\*\s*([\s\S]*?)\s*\*\//)
311
+ if (match?.[1]) {
312
+ const widthMatch = match[1].match(/@previewWidth\s+(\d+)/)
313
+ if (widthMatch?.[1]) {
314
+ return parseInt(widthMatch[1], 10)
315
+ }
316
+ }
317
+ return undefined
318
+ }
319
+ }
320
+
321
+ /**
322
+ * Parse component usage in an Astro file to extract prop values
323
+ */
324
+ export function parseComponentUsage(
325
+ content: string,
326
+ componentName: string,
327
+ ): Array<{ line: number; props: Record<string, string> }> {
328
+ const usages: Array<{ line: number; props: Record<string, string> }> = []
329
+ const lines = content.split('\n')
330
+
331
+ // Match component usage: <ComponentName prop="value" />
332
+ const componentRegex = new RegExp(
333
+ `<${componentName}\\s+([^>]*?)\\s*\\/?>`,
334
+ 'g',
335
+ )
336
+
337
+ for (let i = 0; i < lines.length; i++) {
338
+ const line = lines[i]
339
+ const lineMatches = line?.matchAll(new RegExp(componentRegex.source, 'g')) || []
340
+
341
+ for (const match of lineMatches) {
342
+ const propsString = match[1]
343
+ const props = parsePropsString(propsString)
344
+
345
+ usages.push({
346
+ line: i + 1,
347
+ props,
348
+ })
349
+ }
350
+ }
351
+
352
+ return usages
353
+ }
354
+
355
+ /**
356
+ * Parse props string from component tag
357
+ */
358
+ function parsePropsString(propsString?: string): Record<string, string> {
359
+ const props: Record<string, string> = {}
360
+ if (!propsString) return props
361
+
362
+ // Match prop="value" or prop='value' or prop={expression} or prop (boolean)
363
+ // For expressions, handle nested braces by counting depth
364
+ const regex = /(\w+)(?:=(?:"([^"]*)"|'([^']*)'|\{))?/g
365
+ let match: RegExpExecArray | null
366
+ while ((match = regex.exec(propsString)) !== null) {
367
+ const name = match[1]
368
+ if (!name) continue
369
+
370
+ if (match[2] !== undefined) {
371
+ props[name] = match[2]
372
+ } else if (match[3] !== undefined) {
373
+ props[name] = match[3]
374
+ } else if (match[0].endsWith('{')) {
375
+ // Expression: count braces to find the matching close
376
+ let depth = 1
377
+ const start = regex.lastIndex
378
+ let i = start
379
+ while (i < propsString.length && depth > 0) {
380
+ if (propsString[i] === '{') depth++
381
+ else if (propsString[i] === '}') depth--
382
+ i++
383
+ }
384
+ props[name] = propsString.slice(start, i - 1)
385
+ regex.lastIndex = i
386
+ } else {
387
+ // Boolean prop
388
+ props[name] = 'true'
389
+ }
390
+ }
391
+
392
+ return props
393
+ }