@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,475 @@
1
+ import fs from 'node:fs/promises'
2
+ import path from 'node:path'
3
+
4
+ export interface SourceLocation {
5
+ file: string
6
+ line: number
7
+ snippet?: string
8
+ type?: 'static' | 'variable' | 'prop' | 'computed'
9
+ variableName?: string
10
+ definitionLine?: number
11
+ }
12
+
13
+ export interface VariableReference {
14
+ name: string
15
+ pattern: string
16
+ definitionLine: number
17
+ }
18
+
19
+ /**
20
+ * Find source file and line number for text content
21
+ */
22
+ export async function findSourceLocation(
23
+ textContent: string,
24
+ tag: string,
25
+ ): Promise<SourceLocation | undefined> {
26
+ const srcDir = path.join(process.cwd(), 'src')
27
+
28
+ try {
29
+ const searchDirs = [
30
+ path.join(srcDir, 'components'),
31
+ path.join(srcDir, 'pages'),
32
+ path.join(srcDir, 'layouts'),
33
+ ]
34
+
35
+ for (const dir of searchDirs) {
36
+ try {
37
+ const result = await searchDirectory(dir, textContent, tag)
38
+ if (result) {
39
+ return result
40
+ }
41
+ } catch {
42
+ // Directory doesn't exist, continue
43
+ }
44
+ }
45
+
46
+ // If not found directly, try searching for prop values in parent components
47
+ for (const dir of searchDirs) {
48
+ try {
49
+ const result = await searchForPropInParents(dir, textContent, tag)
50
+ if (result) {
51
+ return result
52
+ }
53
+ } catch {
54
+ // Directory doesn't exist, continue
55
+ }
56
+ }
57
+ } catch {
58
+ // Search failed
59
+ }
60
+
61
+ return undefined
62
+ }
63
+
64
+ /**
65
+ * Recursively search directory for matching content
66
+ */
67
+ async function searchDirectory(
68
+ dir: string,
69
+ textContent: string,
70
+ tag: string,
71
+ ): Promise<SourceLocation | undefined> {
72
+ try {
73
+ const entries = await fs.readdir(dir, { withFileTypes: true })
74
+
75
+ for (const entry of entries) {
76
+ const fullPath = path.join(dir, entry.name)
77
+
78
+ if (entry.isDirectory()) {
79
+ const result = await searchDirectory(fullPath, textContent, tag)
80
+ if (result) return result
81
+ } else if (entry.isFile() && entry.name.endsWith('.astro')) {
82
+ const result = await searchAstroFile(fullPath, textContent, tag)
83
+ if (result) return result
84
+ }
85
+ }
86
+ } catch {
87
+ // Error reading directory
88
+ }
89
+
90
+ return undefined
91
+ }
92
+
93
+ /**
94
+ * Search a single Astro file for matching content
95
+ */
96
+ async function searchAstroFile(
97
+ filePath: string,
98
+ textContent: string,
99
+ tag: string,
100
+ ): Promise<SourceLocation | undefined> {
101
+ try {
102
+ const content = await fs.readFile(filePath, 'utf-8')
103
+ const lines = content.split('\n')
104
+
105
+ const cleanText = cleanTextForSearch(textContent)
106
+ const textPreview = cleanText.slice(0, Math.min(30, cleanText.length))
107
+
108
+ // Extract variable references from frontmatter
109
+ const variableRefs = extractVariableReferences(content, cleanText)
110
+
111
+ // Collect all potential matches with scores and metadata
112
+ const matches: Array<{
113
+ line: number
114
+ score: number
115
+ type: 'static' | 'variable' | 'prop' | 'computed'
116
+ variableName?: string
117
+ definitionLine?: number
118
+ }> = []
119
+
120
+ // Search for tag usage with matching text or variable
121
+ for (let i = 0; i < lines.length; i++) {
122
+ const line = lines[i]?.trim().toLowerCase()
123
+
124
+ // Look for opening tag
125
+ if (line?.includes(`<${tag.toLowerCase()}`) && !line.startsWith(`</${tag.toLowerCase()}`)) {
126
+ // Collect content from this line and next few lines
127
+ const section = collectSection(lines, i, 5)
128
+ const sectionText = section.toLowerCase()
129
+ const sectionTextOnly = stripHtmlTags(section).toLowerCase()
130
+
131
+ let score = 0
132
+ let matched = false
133
+
134
+ // Check for variable reference match (highest priority)
135
+ if (variableRefs.length > 0) {
136
+ for (const varRef of variableRefs) {
137
+ // Check case-insensitively since sectionText is lowercased
138
+ if (sectionText.includes(`{`) && sectionText.includes(varRef.name.toLowerCase())) {
139
+ score = 100
140
+ matched = true
141
+ // Store match metadata - this is the USAGE line, we need DEFINITION line
142
+ matches.push({
143
+ line: i + 1,
144
+ score,
145
+ type: 'variable',
146
+ variableName: varRef.name,
147
+ definitionLine: varRef.definitionLine,
148
+ })
149
+ break
150
+ }
151
+ }
152
+ }
153
+
154
+ // Check for direct text match (static content)
155
+ if (!matched && cleanText.length > 10 && sectionTextOnly.includes(textPreview)) {
156
+ // Score based on how much of the text matches
157
+ const matchLength = Math.min(cleanText.length, sectionTextOnly.length)
158
+ score = 50 + (matchLength / cleanText.length) * 40
159
+ matched = true
160
+ matches.push({ line: i + 1, score, type: 'static' })
161
+ }
162
+
163
+ // Check for short exact text match (static content)
164
+ if (!matched && cleanText.length > 0 && cleanText.length <= 10 && sectionTextOnly.includes(cleanText)) {
165
+ score = 80
166
+ matched = true
167
+ matches.push({ line: i + 1, score, type: 'static' })
168
+ }
169
+
170
+ // Try matching first few words for longer text (static content)
171
+ if (!matched && cleanText.length > 20) {
172
+ const firstWords = cleanText.split(' ').slice(0, 3).join(' ')
173
+ if (firstWords && sectionTextOnly.includes(firstWords)) {
174
+ score = 40
175
+ matched = true
176
+ matches.push({ line: i + 1, score, type: 'static' })
177
+ }
178
+ }
179
+ }
180
+ }
181
+
182
+ // Return the best match (highest score)
183
+ if (matches.length > 0) {
184
+ const bestMatch = matches.reduce((best, current) => current.score > best.score ? current : best)
185
+
186
+ // Determine the editable line (definition for variables, usage for static)
187
+ const editableLine = bestMatch.type === 'variable' && bestMatch.definitionLine
188
+ ? bestMatch.definitionLine
189
+ : bestMatch.line
190
+
191
+ // Get the complete source snippet (multi-line for static, single line for variables)
192
+ let snippet: string
193
+ if (bestMatch.type === 'static') {
194
+ // For static content, extract the complete tag content with indentation
195
+ snippet = extractCompleteTagSnippet(lines, editableLine - 1, tag)
196
+ } else {
197
+ // For variables/props, just the definition line with indentation
198
+ snippet = lines[editableLine - 1] || ''
199
+ }
200
+
201
+ return {
202
+ file: path.relative(process.cwd(), filePath),
203
+ line: editableLine,
204
+ snippet,
205
+ type: bestMatch.type,
206
+ variableName: bestMatch.variableName,
207
+ definitionLine: bestMatch.type === 'variable' ? bestMatch.definitionLine : undefined,
208
+ }
209
+ }
210
+ } catch {
211
+ // Error reading file
212
+ }
213
+
214
+ return undefined
215
+ }
216
+
217
+ /**
218
+ * Search for prop values passed to components
219
+ */
220
+ async function searchForPropInParents(
221
+ dir: string,
222
+ textContent: string,
223
+ tag: string,
224
+ ): Promise<SourceLocation | undefined> {
225
+ try {
226
+ const entries = await fs.readdir(dir, { withFileTypes: true })
227
+ const cleanText = cleanTextForSearch(textContent)
228
+
229
+ for (const entry of entries) {
230
+ const fullPath = path.join(dir, entry.name)
231
+
232
+ if (entry.isDirectory()) {
233
+ const result = await searchForPropInParents(fullPath, textContent, tag)
234
+ if (result) return result
235
+ } else if (entry.isFile() && entry.name.endsWith('.astro')) {
236
+ const content = await fs.readFile(fullPath, 'utf-8')
237
+ const lines = content.split('\n')
238
+
239
+ // Look for component tags with prop values matching our text
240
+ for (let i = 0; i < lines.length; i++) {
241
+ const line = lines[i]
242
+
243
+ // Match component usage like <ComponentName propName="value" />
244
+ const componentMatch = line?.match(/<([A-Z]\w+)/)
245
+ if (!componentMatch) continue
246
+
247
+ // Collect only the opening tag (until first > or />), not nested content
248
+ let openingTag = ''
249
+ let endLine = i
250
+ for (let j = i; j < Math.min(i + 10, lines.length); j++) {
251
+ openingTag += ' ' + lines[j]
252
+ endLine = j
253
+
254
+ // Stop at the end of opening tag (either /> or >)
255
+ if (lines[j]?.includes('/>')) {
256
+ // Self-closing tag
257
+ break
258
+ } else if (lines[j]?.includes('>')) {
259
+ // Opening tag ends here, don't include nested content
260
+ // Truncate to just the opening tag part
261
+ const tagEndIndex = openingTag.indexOf('>')
262
+ if (tagEndIndex !== -1) {
263
+ openingTag = openingTag.substring(0, tagEndIndex + 1)
264
+ }
265
+ break
266
+ }
267
+ }
268
+
269
+ // Extract all prop values from the opening tag only
270
+ const propMatches = openingTag.matchAll(/(\w+)=["']([^"']+)["']/g)
271
+ for (const match of propMatches) {
272
+ const propName = match[1]
273
+ const propValue = match[2]
274
+
275
+ if (!propValue) {
276
+ continue
277
+ }
278
+
279
+ const normalizedValue = normalizeText(propValue)
280
+
281
+ if (normalizedValue === cleanText) {
282
+ // Find which line actually contains this prop
283
+ let propLine = i
284
+ let propLineIndex = i
285
+
286
+ for (let k = i; k <= endLine; k++) {
287
+ const line = lines[k]
288
+ if (!line) {
289
+ continue
290
+ }
291
+
292
+ if (propName && line.includes(propName) && line.includes(propValue)) {
293
+ propLine = k
294
+ propLineIndex = k
295
+ break
296
+ }
297
+ }
298
+
299
+ // Extract complete component tag starting from where the component tag opens
300
+ const componentSnippetLines: string[] = []
301
+ for (let k = i; k <= endLine; k++) {
302
+ const line = lines[k]
303
+ if (!line) {
304
+ continue
305
+ }
306
+
307
+ componentSnippetLines.push(line)
308
+ }
309
+
310
+ const propSnippet = componentSnippetLines.join('\n')
311
+
312
+ // Found the prop being passed with our text value
313
+ return {
314
+ file: path.relative(process.cwd(), fullPath),
315
+ line: propLine + 1,
316
+ snippet: propSnippet,
317
+ type: 'prop',
318
+ variableName: propName,
319
+ }
320
+ }
321
+ }
322
+ }
323
+ }
324
+ }
325
+ } catch {
326
+ // Error reading directory
327
+ }
328
+
329
+ return undefined
330
+ }
331
+
332
+ /**
333
+ * Extract complete tag snippet including content and indentation
334
+ */
335
+ function extractCompleteTagSnippet(lines: string[], startLine: number, tag: string): string {
336
+ const snippetLines: string[] = []
337
+ let depth = 0
338
+ let foundClosing = false
339
+
340
+ // Start from the opening tag line
341
+ for (let i = startLine; i < Math.min(startLine + 20, lines.length); i++) {
342
+ const line = lines[i]
343
+
344
+ if (!line) {
345
+ continue
346
+ }
347
+
348
+ snippetLines.push(line)
349
+
350
+ // Count opening and closing tags
351
+ const openTags = (line.match(new RegExp(`<${tag}[\\s>]`, 'gi')) || []).length
352
+ const selfClosing = (line.match(new RegExp(`<${tag}[^>]*/>`, 'gi')) || []).length
353
+ const closeTags = (line.match(new RegExp(`</${tag}>`, 'gi')) || []).length
354
+
355
+ depth += openTags - selfClosing - closeTags
356
+
357
+ // If we found a self-closing tag or closed all tags, we're done
358
+ if (selfClosing > 0 || (depth <= 0 && (closeTags > 0 || openTags > 0))) {
359
+ foundClosing = true
360
+ break
361
+ }
362
+ }
363
+
364
+ // If we didn't find closing tag, just return the first line
365
+ if (!foundClosing && snippetLines.length > 1) {
366
+ return snippetLines[0]!
367
+ }
368
+
369
+ return snippetLines.join('\n')
370
+ }
371
+
372
+ /**
373
+ * Extract variable references from frontmatter
374
+ */
375
+ function extractVariableReferences(content: string, targetText: string): VariableReference[] {
376
+ const refs: VariableReference[] = []
377
+ const frontmatterEnd = content.indexOf('---', 3)
378
+
379
+ if (frontmatterEnd <= 0) return refs
380
+
381
+ const frontmatter = content.substring(0, frontmatterEnd)
382
+ const lines = frontmatter.split('\n')
383
+
384
+ for (const line of lines) {
385
+ const trimmed = line.trim()
386
+
387
+ // Match quoted text (handling escaped quotes)
388
+ // Try single quotes with escaped quotes
389
+ let quotedMatch = trimmed.match(/'((?:[^'\\]|\\.)*)'/)
390
+ if (!quotedMatch) {
391
+ // Try double quotes with escaped quotes
392
+ quotedMatch = trimmed.match(/"((?:[^"\\]|\\.)*)"/)
393
+ }
394
+ if (!quotedMatch) {
395
+ // Try backticks (template literals) - but only if no ${} interpolation
396
+ const backtickMatch = trimmed.match(/`([^`]*)`/)
397
+ if (backtickMatch && !backtickMatch[1]?.includes('${')) {
398
+ quotedMatch = backtickMatch
399
+ }
400
+ }
401
+ if (!quotedMatch?.[1]) continue
402
+
403
+ const value = normalizeText(quotedMatch[1])
404
+ const normalizedTarget = normalizeText(targetText)
405
+
406
+ if (value !== normalizedTarget) continue
407
+
408
+ // Try to extract variable name and line number
409
+ const lineNumber = lines.indexOf(line) + 1
410
+
411
+ // Pattern 1: Object property "key: 'value'"
412
+ const propMatch = trimmed.match(/(\w+)\s*:\s*['"`]/)
413
+ if (propMatch?.[1]) {
414
+ refs.push({
415
+ name: propMatch[1],
416
+ pattern: `{.*${propMatch[1]}`,
417
+ definitionLine: lineNumber,
418
+ })
419
+ continue
420
+ }
421
+
422
+ // Pattern 2: Variable declaration "const name = 'value'"
423
+ const varMatch = trimmed.match(/(?:const|let|var)\s+(\w+)(?:\s*:\s*\w+)?\s*=/)
424
+ if (varMatch?.[1]) {
425
+ refs.push({
426
+ name: varMatch[1],
427
+ pattern: `{${varMatch[1]}}`,
428
+ definitionLine: lineNumber,
429
+ })
430
+ }
431
+ }
432
+
433
+ return refs
434
+ }
435
+
436
+ /**
437
+ * Collect text from multiple lines
438
+ */
439
+ function collectSection(lines: string[], startLine: number, numLines: number): string {
440
+ let text = ''
441
+ for (let i = startLine; i < Math.min(startLine + numLines, lines.length); i++) {
442
+ text += ' ' + lines[i]?.trim().replace(/\s+/g, ' ')
443
+ }
444
+ return text
445
+ }
446
+
447
+ /**
448
+ * Strip HTML tags from text
449
+ */
450
+ function stripHtmlTags(text: string): string {
451
+ return text.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim()
452
+ }
453
+
454
+ /**
455
+ * Normalize text for comparison (handles escaping and entities)
456
+ */
457
+ function normalizeText(text: string): string {
458
+ return text
459
+ .trim()
460
+ .replace(/\\'/g, "'") // Escaped single quotes
461
+ .replace(/\\"/g, '"') // Escaped double quotes
462
+ .replace(/&#39;/g, "'") // HTML entity for apostrophe
463
+ .replace(/&quot;/g, '"') // HTML entity for quote
464
+ .replace(/&apos;/g, "'") // HTML entity for apostrophe (alternative)
465
+ .replace(/&amp;/g, '&') // HTML entity for ampersand
466
+ .replace(/\s+/g, ' ') // Normalize whitespace
467
+ .toLowerCase()
468
+ }
469
+
470
+ /**
471
+ * Clean text for search comparison
472
+ */
473
+ function cleanTextForSearch(text: string): string {
474
+ return normalizeText(text)
475
+ }
@@ -0,0 +1,6 @@
1
+ {
2
+ "extends": "../tsconfig.settings.json",
3
+ "compilerOptions": {
4
+ "outDir": "../dist/types"
5
+ }
6
+ }
package/src/types.ts ADDED
@@ -0,0 +1,57 @@
1
+ export interface CmsMarkerOptions {
2
+ attributeName?: string
3
+ includeTags?: string[] | null
4
+ excludeTags?: string[]
5
+ includeEmptyText?: boolean
6
+ generateManifest?: boolean
7
+ manifestFile?: string
8
+ markComponents?: boolean
9
+ componentDirs?: string[]
10
+ }
11
+
12
+ export interface ComponentProp {
13
+ name: string
14
+ type: string
15
+ required: boolean
16
+ defaultValue?: string
17
+ description?: string
18
+ }
19
+
20
+ export interface ComponentDefinition {
21
+ name: string
22
+ file: string
23
+ props: ComponentProp[]
24
+ description?: string
25
+ slots?: string[]
26
+ }
27
+
28
+ export interface ManifestEntry {
29
+ id: string
30
+ file: string
31
+ tag: string
32
+ text: string
33
+ sourcePath?: string
34
+ sourceLine?: number
35
+ sourceSnippet?: string
36
+ sourceType?: 'static' | 'variable' | 'prop' | 'computed'
37
+ variableName?: string
38
+ childCmsIds?: string[]
39
+ parentComponentId?: string
40
+ }
41
+
42
+ export interface ComponentInstance {
43
+ id: string
44
+ componentName: string
45
+ file: string
46
+ sourcePath: string
47
+ sourceLine: number
48
+ props: Record<string, any>
49
+ slots?: Record<string, string>
50
+ parentId?: string
51
+ }
52
+
53
+ export interface CmsManifest {
54
+ entries: Record<string, ManifestEntry>
55
+ components: Record<string, ComponentInstance>
56
+ componentDefinitions: Record<string, ComponentDefinition>
57
+ }
@@ -0,0 +1,45 @@
1
+ import type { Plugin } from 'vite'
2
+ import type { ManifestWriter } from './manifest-writer'
3
+ import type { CmsMarkerOptions, ComponentDefinition } from './types'
4
+ import { createAstroTransformPlugin } from './astro-transform'
5
+
6
+ export interface VitePluginContext {
7
+ manifestWriter: ManifestWriter
8
+ componentDefinitions: Record<string, ComponentDefinition>
9
+ config: Required<CmsMarkerOptions>
10
+ idCounter: { value: number }
11
+ }
12
+
13
+ export function createVitePlugin(context: VitePluginContext): Plugin[] {
14
+ const { manifestWriter, componentDefinitions, config } = context
15
+
16
+ const virtualManifestPlugin: Plugin = {
17
+ name: 'cms-marker-virtual-manifest',
18
+ resolveId(id) {
19
+ if (id === '/@cms/manifest' || id === 'virtual:cms-manifest') {
20
+ return '\0virtual:cms-manifest'
21
+ }
22
+ if (id === '/@cms/components' || id === 'virtual:cms-components') {
23
+ return '\0virtual:cms-components'
24
+ }
25
+ },
26
+ load(id) {
27
+ if (id === '\0virtual:cms-manifest') {
28
+ return `export default ${JSON.stringify(manifestWriter.getGlobalManifest())};`
29
+ }
30
+ if (id === '\0virtual:cms-components') {
31
+ return `export default ${JSON.stringify(componentDefinitions)};`
32
+ }
33
+ },
34
+ }
35
+
36
+ // Create the Astro transform plugin to inject source location attributes
37
+ const astroTransformPlugin = createAstroTransformPlugin({
38
+ markComponents: config.markComponents,
39
+ })
40
+
41
+ // Note: We cannot use transformIndexHtml for static Astro builds because
42
+ // Astro generates HTML files directly without going through Vite's HTML pipeline.
43
+ // HTML processing is done in build-processor.ts after pages are generated.
44
+ return [astroTransformPlugin, virtualManifestPlugin]
45
+ }