@nuasite/cms-marker 0.0.42

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.
@@ -0,0 +1,382 @@
1
+ import fs from 'node:fs/promises'
2
+ import path from 'node:path'
3
+ import type { ComponentDefinition, ComponentProp } from './types'
4
+
5
+ /**
6
+ * Scans Astro component files and extracts their definitions including props
7
+ */
8
+ export class ComponentRegistry {
9
+ private components: Map<string, ComponentDefinition> = new Map()
10
+ private componentDirs: string[]
11
+
12
+ constructor(componentDirs: string[] = ['src/components']) {
13
+ this.componentDirs = componentDirs
14
+ }
15
+
16
+ /**
17
+ * Scan all component directories and build the registry
18
+ */
19
+ async scan(): Promise<void> {
20
+ for (const dir of this.componentDirs) {
21
+ const fullPath = path.join(process.cwd(), dir)
22
+ try {
23
+ await this.scanDirectory(fullPath, dir)
24
+ } catch {
25
+ // Directory doesn't exist, skip
26
+ }
27
+ }
28
+ }
29
+
30
+ /**
31
+ * Get all registered components
32
+ */
33
+ getComponents(): Record<string, ComponentDefinition> {
34
+ return Object.fromEntries(this.components)
35
+ }
36
+
37
+ /**
38
+ * Get a specific component by name
39
+ */
40
+ getComponent(name: string): ComponentDefinition | undefined {
41
+ return this.components.get(name)
42
+ }
43
+
44
+ /**
45
+ * Scan a directory recursively for .astro files
46
+ */
47
+ private async scanDirectory(dir: string, relativePath: string): Promise<void> {
48
+ const entries = await fs.readdir(dir, { withFileTypes: true })
49
+
50
+ for (const entry of entries) {
51
+ const fullPath = path.join(dir, entry.name)
52
+ const relPath = path.join(relativePath, entry.name)
53
+
54
+ if (entry.isDirectory()) {
55
+ await this.scanDirectory(fullPath, relPath)
56
+ } else if (entry.isFile() && entry.name.endsWith('.astro')) {
57
+ await this.parseComponent(fullPath, relPath)
58
+ }
59
+ }
60
+ }
61
+
62
+ /**
63
+ * Parse a single Astro component file
64
+ */
65
+ private async parseComponent(filePath: string, relativePath: string): Promise<void> {
66
+ try {
67
+ const content = await fs.readFile(filePath, 'utf-8')
68
+ const componentName = path.basename(filePath, '.astro')
69
+
70
+ const props = await this.extractProps(content)
71
+ const slots = this.extractSlots(content)
72
+ const description = this.extractDescription(content)
73
+
74
+ this.components.set(componentName, {
75
+ name: componentName,
76
+ file: relativePath,
77
+ props,
78
+ slots: slots.length > 0 ? slots : undefined,
79
+ description,
80
+ })
81
+ } catch (error) {
82
+ console.warn(`[ComponentRegistry] Failed to parse ${filePath}:`, error)
83
+ }
84
+ }
85
+
86
+ /**
87
+ * Parse Props content and extract individual property definitions
88
+ * Handles multi-line properties with nested types
89
+ */
90
+ private parsePropsContent(propsContent: string): ComponentProp[] {
91
+ const props: ComponentProp[] = []
92
+ let i = 0
93
+ const content = propsContent.trim()
94
+
95
+ while (i < content.length) {
96
+ // Skip whitespace and newlines
97
+ while (i < content.length && /\s/.test(content[i])) i++
98
+ if (i >= content.length) break
99
+
100
+ // Skip comments
101
+ if (content[i] === '/' && content[i + 1] === '/') {
102
+ // Skip to end of line
103
+ while (i < content.length && content[i] !== '\n') i++
104
+ continue
105
+ }
106
+
107
+ if (content[i] === '/' && content[i + 1] === '*') {
108
+ // Skip block comment
109
+ while (i < content.length - 1 && !(content[i] === '*' && content[i + 1] === '/')) i++
110
+ i += 2
111
+ continue
112
+ }
113
+
114
+ // Extract property name
115
+ const nameStart = i
116
+ while (i < content.length && /\w/.test(content[i])) i++
117
+ const name = content.substring(nameStart, i)
118
+
119
+ if (!name) break
120
+
121
+ // Skip whitespace
122
+ while (i < content.length && /\s/.test(content[i])) i++
123
+
124
+ // Check for optional marker
125
+ const optional = content[i] === '?'
126
+ if (optional) i++
127
+
128
+ // Skip whitespace
129
+ while (i < content.length && /\s/.test(content[i])) i++
130
+
131
+ // Expect colon
132
+ if (content[i] !== ':') break
133
+ i++
134
+
135
+ // Skip whitespace
136
+ while (i < content.length && /\s/.test(content[i])) i++
137
+
138
+ // Extract type (up to semicolon, handling nested braces)
139
+ const typeStart = i
140
+ let braceDepth = 0
141
+ let angleDepth = 0
142
+ while (i < content.length) {
143
+ if (content[i] === '{') braceDepth++
144
+ else if (content[i] === '}') braceDepth--
145
+ else if (content[i] === '<') angleDepth++
146
+ else if (content[i] === '>') angleDepth--
147
+ else if (content[i] === ';' && braceDepth === 0 && angleDepth === 0) break
148
+ i++
149
+ }
150
+
151
+ const type = content.substring(typeStart, i).trim()
152
+
153
+ // Skip the semicolon
154
+ if (content[i] === ';') i++
155
+
156
+ // Skip whitespace
157
+ while (i < content.length && /[ \t]/.test(content[i])) i++
158
+
159
+ // Check for inline comment
160
+ let description: string | undefined
161
+ if (content[i] === '/' && content[i + 1] === '/') {
162
+ i += 2
163
+ const commentStart = i
164
+ while (i < content.length && content[i] !== '\n') i++
165
+ description = content.substring(commentStart, i).trim()
166
+ }
167
+
168
+ if (name && type) {
169
+ props.push({
170
+ name,
171
+ type,
172
+ required: !optional,
173
+ description,
174
+ })
175
+ }
176
+ }
177
+
178
+ return props
179
+ }
180
+
181
+ /**
182
+ * Extract content between balanced braces after a pattern match
183
+ * Properly handles nested objects
184
+ */
185
+ private extractBalancedBraces(text: string, pattern: RegExp): string | null {
186
+ const match = text.match(pattern)
187
+ if (!match || match.index === undefined) return null
188
+
189
+ // Find the opening brace position (right after the match)
190
+ const startIndex = match.index + match[0].length
191
+ let depth = 1 // We already have one opening brace
192
+ let i = startIndex
193
+
194
+ // Find the matching closing brace
195
+ while (i < text.length && depth > 0) {
196
+ if (text[i] === '{') {
197
+ depth++
198
+ } else if (text[i] === '}') {
199
+ depth--
200
+ }
201
+ i++
202
+ }
203
+
204
+ if (depth !== 0) return null // Unbalanced braces
205
+
206
+ // Extract content between braces (excluding the braces themselves)
207
+ return text.substring(startIndex, i - 1)
208
+ }
209
+
210
+ /**
211
+ * Extract props from component frontmatter
212
+ */
213
+ private async extractProps(content: string): Promise<ComponentProp[]> {
214
+ const props: ComponentProp[] = []
215
+
216
+ // Find the frontmatter section
217
+ const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---/)
218
+ if (!frontmatterMatch) return props
219
+
220
+ const frontmatter = frontmatterMatch[1]
221
+
222
+ // Look for Props interface
223
+ const propsInterfaceContent = this.extractBalancedBraces(frontmatter, /interface\s+Props\s*\{/)
224
+ if (propsInterfaceContent) {
225
+ const extractedProps = this.parsePropsContent(propsInterfaceContent)
226
+ props.push(...extractedProps)
227
+ }
228
+
229
+ // Look for type Props = { ... }
230
+ if (props.length === 0) {
231
+ const typePropsContent = this.extractBalancedBraces(frontmatter, /type\s+Props\s*=\s*\{/)
232
+ if (typePropsContent) {
233
+ const extractedProps = this.parsePropsContent(typePropsContent)
234
+ props.push(...extractedProps)
235
+ }
236
+ }
237
+
238
+ const destructureMatch = frontmatter?.match(/const\s*\{([^}]+)\}\s*=\s*Astro\.props/)
239
+ if (destructureMatch) {
240
+ const destructureContent = destructureMatch[1]
241
+
242
+ const defaultMatches = destructureContent?.matchAll(/(\w+)\s*=\s*(['"`]?)([^'"`},]+)\2/g) ?? []
243
+ for (const match of defaultMatches) {
244
+ const propName = match[1]
245
+ const defaultValue = match[3]
246
+ const existingProp = props.find(p => p.name === propName)
247
+ if (existingProp) {
248
+ existingProp.defaultValue = defaultValue
249
+ }
250
+ }
251
+ }
252
+
253
+ return props
254
+ }
255
+
256
+ /**
257
+ * Parse a single prop line from interface/type
258
+ */
259
+ private parsePropLine(line: string): ComponentProp | null {
260
+ const trimmed = line.trim()
261
+ if (!trimmed || trimmed.startsWith('//') || trimmed.startsWith('/*')) return null
262
+
263
+ // Match: propName?: type; or propName: type;
264
+ const match = trimmed.match(/^(\w+)(\?)?:\s*([^;]+);?\s*(\/\/.*)?$/)
265
+ if (!match) return null
266
+
267
+ const [, name, optional, typeStr, comment] = match
268
+
269
+ if (!name || !typeStr) return null
270
+
271
+ return {
272
+ name,
273
+ type: typeStr?.trim(),
274
+ required: !optional,
275
+ description: comment ? comment.replace(/^\/\/\s*/, '').trim() : undefined,
276
+ }
277
+ }
278
+
279
+ /**
280
+ * Extract slot names from template
281
+ */
282
+ private extractSlots(content: string): string[] {
283
+ const slots: string[] = []
284
+
285
+ // Find <slot> elements with name attribute
286
+ const slotMatches = content.matchAll(/<slot\s+name=["']([^"']+)["']/g)
287
+ for (const match of slotMatches) {
288
+ if (match[1]) {
289
+ slots.push(match[1])
290
+ }
291
+ }
292
+
293
+ // Check for default slot (unnamed slot) - match any <slot> tag without a name attribute
294
+ const allSlotTags = content.matchAll(/<slot(?:\s+[^>]*)?\s*\/?>/g)
295
+ for (const match of allSlotTags) {
296
+ const tag = match[0]
297
+ // Check if this slot tag doesn't have a name attribute
298
+ if (!/name\s*=/.test(tag)) {
299
+ if (!slots.includes('default')) {
300
+ slots.unshift('default')
301
+ }
302
+ break // Only need to find one default slot
303
+ }
304
+ }
305
+
306
+ return slots
307
+ }
308
+
309
+ /**
310
+ * Extract component description from JSDoc comment
311
+ */
312
+ private extractDescription(content: string): string | undefined {
313
+ // Look for JSDoc comment at the start of frontmatter
314
+ const match = content.match(/^---\n\/\*\*\s*([\s\S]*?)\s*\*\//)
315
+ if (match?.[1]) {
316
+ return match[1]
317
+ .split('\n')
318
+ .map(line => line.replace(/^\s*\*\s?/, '').trim())
319
+ .filter(Boolean)
320
+ .join(' ')
321
+ }
322
+ return undefined
323
+ }
324
+ }
325
+
326
+ /**
327
+ * Parse component usage in an Astro file to extract prop values
328
+ */
329
+ export function parseComponentUsage(
330
+ content: string,
331
+ componentName: string,
332
+ ): Array<{ line: number; props: Record<string, string> }> {
333
+ const usages: Array<{ line: number; props: Record<string, string> }> = []
334
+ const lines = content.split('\n')
335
+
336
+ // Match component usage: <ComponentName prop="value" />
337
+ const componentRegex = new RegExp(
338
+ `<${componentName}\\s+([^>]*?)\\s*\\/?>`,
339
+ 'g',
340
+ )
341
+
342
+ let lineIndex = 0
343
+ let charIndex = 0
344
+
345
+ for (let i = 0; i < lines.length; i++) {
346
+ const line = lines[i]
347
+ const lineMatches = line?.matchAll(new RegExp(componentRegex.source, 'g')) || []
348
+
349
+ for (const match of lineMatches) {
350
+ const propsString = match[1]
351
+ const props = parsePropsString(propsString)
352
+
353
+ usages.push({
354
+ line: i + 1,
355
+ props,
356
+ })
357
+ }
358
+ }
359
+
360
+ return usages
361
+ }
362
+
363
+ /**
364
+ * Parse props string from component tag
365
+ */
366
+ function parsePropsString(propsString?: string): Record<string, string> {
367
+ const props: Record<string, string> = {}
368
+
369
+ // Match prop="value" or prop={expression} or prop (boolean)
370
+ const propMatches = propsString?.matchAll(
371
+ /(\w+)(?:=(?:["']([^"']*)["']|\{([^}]*)\}))?/g,
372
+ ) || []
373
+
374
+ for (const match of propMatches) {
375
+ const [, name, stringValue, expressionValue] = match
376
+ if (name) {
377
+ props[name] = stringValue ?? expressionValue ?? 'true'
378
+ }
379
+ }
380
+
381
+ return props
382
+ }
@@ -0,0 +1,183 @@
1
+ import type { ViteDevServer } from 'vite'
2
+ import { processHtml } from './html-processor'
3
+ import type { ManifestWriter } from './manifest-writer'
4
+ import type { CmsMarkerOptions, ComponentDefinition } from './types'
5
+
6
+ /**
7
+ * Get the normalized page path from a URL
8
+ * For example: /about/ -> /about
9
+ * /about -> /about
10
+ * / -> /
11
+ */
12
+ function normalizePagePath(url: string): string {
13
+ // Remove query string and hash
14
+ let pagePath = url.split('?')[0]?.split('#')[0] ?? ''
15
+ // Remove trailing slash (but keep root /)
16
+ if (pagePath.length > 1 && pagePath.endsWith('/')) {
17
+ pagePath = pagePath.slice(0, -1)
18
+ }
19
+ return pagePath || '/'
20
+ }
21
+
22
+ export function createDevMiddleware(
23
+ server: ViteDevServer,
24
+ config: Required<CmsMarkerOptions>,
25
+ manifestWriter: ManifestWriter,
26
+ componentDefinitions: Record<string, ComponentDefinition>,
27
+ idCounter: { value: number },
28
+ ) {
29
+ // Serve global CMS manifest (component definitions and settings)
30
+ server.middlewares.use((req, res, next) => {
31
+ if (req.url === '/cms-manifest.json') {
32
+ res.setHeader('Content-Type', 'application/json')
33
+ res.setHeader('Access-Control-Allow-Origin', '*')
34
+ res.end(JSON.stringify(
35
+ {
36
+ componentDefinitions,
37
+ },
38
+ null,
39
+ 2,
40
+ ))
41
+ return
42
+ }
43
+ next()
44
+ })
45
+
46
+ // Serve per-page manifest endpoints (e.g., /about.json for /about page)
47
+ server.middlewares.use((req, res, next) => {
48
+ const url = req.url || ''
49
+
50
+ // Match /*.json pattern (but not files that actually exist)
51
+ const match = url.match(/^\/(.*)\.json$/)
52
+ if (match) {
53
+ // Convert manifest path to page path
54
+ // e.g., /about.json -> /about
55
+ // /index.json -> /
56
+ // /blog/post.json -> /blog/post
57
+ let pagePath = '/' + match[1]
58
+ if (pagePath === '/index') {
59
+ pagePath = '/'
60
+ }
61
+
62
+ const pageData = manifestWriter.getPageManifest(pagePath)
63
+
64
+ // Only serve if we have manifest data for this page
65
+ if (pageData) {
66
+ res.setHeader('Content-Type', 'application/json')
67
+ res.setHeader('Access-Control-Allow-Origin', '*')
68
+ res.end(JSON.stringify(
69
+ {
70
+ page: pagePath,
71
+ entries: pageData.entries,
72
+ components: pageData.components,
73
+ componentDefinitions,
74
+ },
75
+ null,
76
+ 2,
77
+ ))
78
+ return
79
+ }
80
+ }
81
+ next()
82
+ })
83
+
84
+ // Transform HTML responses
85
+ server.middlewares.use((req, res, next) => {
86
+ const originalWrite = res.write
87
+ const originalEnd = res.end
88
+ const chunks: Buffer[] = []
89
+ const requestUrl = req.url || 'unknown'
90
+
91
+ // Intercept response chunks
92
+ res.write = function(chunk: any, ...args: any[]) {
93
+ if (chunk) {
94
+ chunks.push(Buffer.from(chunk))
95
+ }
96
+ return true
97
+ } as any
98
+
99
+ res.end = function(chunk: any, ...args: any[]) {
100
+ if (chunk) {
101
+ chunks.push(Buffer.from(chunk))
102
+ }
103
+
104
+ // Check if this is an HTML response
105
+ const contentType = res.getHeader('content-type')
106
+ if (contentType && typeof contentType === 'string' && contentType.includes('text/html')) {
107
+ const html = Buffer.concat(chunks).toString('utf8')
108
+ const pagePath = normalizePagePath(requestUrl)
109
+
110
+ // Process HTML asynchronously
111
+ processHtmlForDev(html, pagePath, config, idCounter)
112
+ .then(({ html: transformed, entries, components }) => {
113
+ // Store in manifest writer
114
+ manifestWriter.addPage(pagePath, entries, components)
115
+
116
+ // Restore original methods and send transformed HTML
117
+ res.write = originalWrite
118
+ res.end = originalEnd
119
+
120
+ return res.end(transformed, ...args)
121
+ })
122
+ .catch((error) => {
123
+ console.error('[astro-cms-marker] Error transforming HTML:', error)
124
+
125
+ // Restore original methods and send original content
126
+ res.write = originalWrite
127
+ res.end = originalEnd
128
+
129
+ if (chunks.length > 0) {
130
+ return res.end(Buffer.concat(chunks), ...args)
131
+ }
132
+ return res.end(...args)
133
+ })
134
+ return
135
+ }
136
+
137
+ // Restore original methods and send original content
138
+ res.write = originalWrite
139
+ res.end = originalEnd
140
+
141
+ if (chunks.length > 0) {
142
+ return res.end(Buffer.concat(chunks), ...args)
143
+ }
144
+ return res.end(...args)
145
+ } as any
146
+
147
+ next()
148
+ })
149
+ }
150
+
151
+ async function processHtmlForDev(
152
+ html: string,
153
+ pagePath: string,
154
+ config: Required<CmsMarkerOptions>,
155
+ idCounter: { value: number },
156
+ ) {
157
+ // In dev mode, reset counter per page for consistent IDs during HMR
158
+ let pageCounter = 0
159
+ const idGenerator = () => `cms-${pageCounter++}`
160
+
161
+ const result = await processHtml(
162
+ html,
163
+ pagePath,
164
+ {
165
+ attributeName: config.attributeName,
166
+ includeTags: config.includeTags,
167
+ excludeTags: config.excludeTags,
168
+ includeEmptyText: config.includeEmptyText,
169
+ generateManifest: config.generateManifest,
170
+ markComponents: config.markComponents,
171
+ componentDirs: config.componentDirs,
172
+ },
173
+ idGenerator,
174
+ )
175
+
176
+ // In dev mode, we use the source info from Astro compiler attributes
177
+ // which is already extracted by html-processor, so no need to call findSourceLocation
178
+ return {
179
+ html: result.html,
180
+ entries: result.entries,
181
+ components: result.components,
182
+ }
183
+ }