@nuasite/cms-marker 0.0.48 → 0.0.52

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.
package/package.json CHANGED
@@ -14,13 +14,13 @@
14
14
  "directory": "packages/cms-marker"
15
15
  },
16
16
  "license": "Apache-2.0",
17
- "version": "0.0.48",
17
+ "version": "0.0.52",
18
18
  "module": "src/index.ts",
19
19
  "types": "src/index.ts",
20
20
  "type": "module",
21
21
  "dependencies": {
22
22
  "@astrojs/compiler": "^2.13.0",
23
- "astro": "^5.12.4",
23
+ "astro": "^5.16.6",
24
24
  "node-html-parser": "^6.1.13"
25
25
  },
26
26
  "devDependencies": {
@@ -9,18 +9,18 @@ export interface AstroTransformOptions {
9
9
  }
10
10
 
11
11
  /**
12
- * Vite plugin that transforms .astro files to inject source location metadata
13
- * This runs during Astro's compilation phase and adds data-astro-source-file and
14
- * data-astro-source-line attributes to HTML elements in the template.
12
+ * Vite plugin that transforms .astro files to inject source location metadata.
15
13
  *
16
- * NOTE: Component marking is NOT done here because modifying component tags
17
- * in the raw .astro source breaks Astro's JSX-like parser. Component marking
18
- * is done at the HTML output level instead (in dev-middleware and build-processor).
14
+ * NOTE: This plugin is currently DISABLED because Astro's native compiler already
15
+ * injects `data-astro-source-file` and `data-astro-source-loc` attributes in dev mode.
16
+ * Our html-processor now recognizes both Astro's native attributes and our custom ones.
19
17
  *
20
- * IMPORTANT: This plugin should only run in dev mode. During build, modifying
21
- * .astro source files can cause Vite's build-import-analysis to fail with
22
- * parsing errors. In build mode, source locations are extracted from Astro's
23
- * compiler output in build-processor.ts instead.
18
+ * HISTORICAL CONTEXT: This plugin was created before we discovered Astro's native
19
+ * source tracking. It caused Vite's build-import-analysis to fail with parsing errors
20
+ * because modifying .astro source files confuses Vite's JavaScript parser.
21
+ *
22
+ * KEEPING FOR: Potential future use cases where custom source attribute injection
23
+ * might be needed, or for testing purposes.
24
24
  */
25
25
  export function createAstroTransformPlugin(options: AstroTransformOptions = {}): Plugin {
26
26
  // Component marking is intentionally disabled at the transform level
@@ -4,8 +4,8 @@ import path from 'node:path'
4
4
  import { fileURLToPath } from 'node:url'
5
5
  import { processHtml } from './html-processor'
6
6
  import type { ManifestWriter } from './manifest-writer'
7
- import { findSourceLocation } from './source-finder'
8
- import type { CmsMarkerOptions } from './types'
7
+ import { findCollectionSource, findMarkdownSourceLocation, findSourceLocation, parseMarkdownContent } from './source-finder'
8
+ import type { CmsMarkerOptions, CollectionEntry } from './types'
9
9
 
10
10
  // Concurrency limit for parallel processing
11
11
  const MAX_CONCURRENT = 10
@@ -48,6 +48,24 @@ async function processFile(
48
48
  const pagePath = getPagePath(filePath, outDir)
49
49
  const html = await fs.readFile(filePath, 'utf-8')
50
50
 
51
+ // First, try to detect if this page is from a content collection
52
+ // We need to know this BEFORE processing HTML to skip marking markdown-rendered elements
53
+ const collectionInfo = await findCollectionSource(pagePath, config.contentDir)
54
+ const isCollectionPage = !!collectionInfo
55
+
56
+ // Parse markdown content early if this is a collection page
57
+ // We need the body content to find the wrapper element during HTML processing
58
+ let mdContent: Awaited<ReturnType<typeof parseMarkdownContent>> | undefined
59
+ if (collectionInfo) {
60
+ mdContent = await parseMarkdownContent(collectionInfo)
61
+ }
62
+
63
+ // Get the first non-empty line of the markdown body for wrapper detection
64
+ const bodyFirstLine = mdContent?.body
65
+ ?.split('\n')
66
+ .find((line) => line.trim().length > 0)
67
+ ?.trim()
68
+
51
69
  // Create ID generator - use atomic increment
52
70
  const pageIdStart = idCounter.value
53
71
  const idGenerator = () => `cms-${idCounter.value++}`
@@ -63,18 +81,57 @@ async function processFile(
63
81
  generateManifest: config.generateManifest,
64
82
  markComponents: config.markComponents,
65
83
  componentDirs: config.componentDirs,
84
+ // Skip marking markdown-rendered content on collection pages
85
+ // The markdown body is treated as a single editable unit
86
+ skipMarkdownContent: isCollectionPage,
87
+ // Pass collection info for wrapper element marking
88
+ collectionInfo: collectionInfo
89
+ ? { name: collectionInfo.name, slug: collectionInfo.slug, bodyFirstLine }
90
+ : undefined,
66
91
  },
67
92
  idGenerator,
68
93
  )
69
94
 
70
95
  // During build, source location attributes are not injected by astro-transform.ts
71
96
  // (disabled to avoid Vite parse errors). Use findSourceLocation to look up source files.
97
+
98
+ let collectionEntry: CollectionEntry | undefined
99
+
100
+ // Build collection entry if this is a collection page
101
+ if (collectionInfo && mdContent) {
102
+ collectionEntry = {
103
+ collectionName: mdContent.collectionName,
104
+ collectionSlug: mdContent.collectionSlug,
105
+ sourcePath: mdContent.file,
106
+ frontmatter: mdContent.frontmatter,
107
+ body: mdContent.body,
108
+ bodyStartLine: mdContent.bodyStartLine,
109
+ wrapperId: result.collectionWrapperId,
110
+ }
111
+ }
112
+
72
113
  for (const entry of Object.values(result.entries)) {
73
114
  // Skip entries that already have source info from component detection
74
115
  if (entry.sourcePath && !entry.sourcePath.endsWith('.html')) {
75
116
  continue
76
117
  }
77
118
 
119
+ // Try to find source in collection markdown frontmatter first
120
+ if (collectionInfo) {
121
+ const mdSource = await findMarkdownSourceLocation(entry.text, collectionInfo)
122
+ if (mdSource) {
123
+ entry.sourcePath = mdSource.file
124
+ entry.sourceLine = mdSource.line
125
+ entry.sourceSnippet = mdSource.snippet
126
+ entry.sourceType = mdSource.type
127
+ entry.variableName = mdSource.variableName
128
+ entry.collectionName = mdSource.collectionName
129
+ entry.collectionSlug = mdSource.collectionSlug
130
+ continue
131
+ }
132
+ }
133
+
134
+ // Fall back to searching Astro files
78
135
  const sourceLocation = await findSourceLocation(entry.text, entry.tag)
79
136
  if (sourceLocation) {
80
137
  entry.sourcePath = sourceLocation.file
@@ -86,7 +143,7 @@ async function processFile(
86
143
  }
87
144
 
88
145
  // Add to manifest writer (handles per-page manifest writes)
89
- manifestWriter.addPage(pagePath, result.entries, result.components)
146
+ manifestWriter.addPage(pagePath, result.entries, result.components, collectionEntry)
90
147
 
91
148
  // Write transformed HTML back
92
149
  await fs.writeFile(filePath, result.html, 'utf-8')
@@ -1,7 +1,8 @@
1
1
  import type { ViteDevServer } from 'vite'
2
2
  import { processHtml } from './html-processor'
3
3
  import type { ManifestWriter } from './manifest-writer'
4
- import type { CmsMarkerOptions, ComponentDefinition } from './types'
4
+ import { findCollectionSource, parseMarkdownContent } from './source-finder'
5
+ import type { CollectionEntry, CmsMarkerOptions, ComponentDefinition } from './types'
5
6
 
6
7
  /**
7
8
  * Get the normalized page path from a URL
@@ -109,9 +110,9 @@ export function createDevMiddleware(
109
110
 
110
111
  // Process HTML asynchronously
111
112
  processHtmlForDev(html, pagePath, config, idCounter)
112
- .then(({ html: transformed, entries, components }) => {
113
+ .then(({ html: transformed, entries, components, collection }) => {
113
114
  // Store in manifest writer
114
- manifestWriter.addPage(pagePath, entries, components)
115
+ manifestWriter.addPage(pagePath, entries, components, collection)
115
116
 
116
117
  // Restore original methods and send transformed HTML
117
118
  res.write = originalWrite
@@ -158,6 +159,22 @@ async function processHtmlForDev(
158
159
  let pageCounter = 0
159
160
  const idGenerator = () => `cms-${pageCounter++}`
160
161
 
162
+ // Check if this is a collection page (e.g., /services/example -> services collection, example slug)
163
+ const collectionInfo = await findCollectionSource(pagePath, config.contentDir)
164
+ const isCollectionPage = !!collectionInfo
165
+
166
+ // Parse markdown content if this is a collection page
167
+ let mdContent: Awaited<ReturnType<typeof parseMarkdownContent>> | undefined
168
+ if (collectionInfo) {
169
+ mdContent = await parseMarkdownContent(collectionInfo)
170
+ }
171
+
172
+ // Get the first non-empty line of the markdown body for wrapper detection
173
+ const bodyFirstLine = mdContent?.body
174
+ ?.split('\n')
175
+ .find((line) => line.trim().length > 0)
176
+ ?.trim()
177
+
161
178
  const result = await processHtml(
162
179
  html,
163
180
  pagePath,
@@ -169,15 +186,37 @@ async function processHtmlForDev(
169
186
  generateManifest: config.generateManifest,
170
187
  markComponents: config.markComponents,
171
188
  componentDirs: config.componentDirs,
189
+ // Skip marking markdown-rendered content on collection pages
190
+ // The markdown body is treated as a single editable unit
191
+ skipMarkdownContent: isCollectionPage,
192
+ // Pass collection info for wrapper element marking
193
+ collectionInfo: collectionInfo
194
+ ? { name: collectionInfo.name, slug: collectionInfo.slug, bodyFirstLine }
195
+ : undefined,
172
196
  },
173
197
  idGenerator,
174
198
  )
175
199
 
200
+ // Build collection entry if this is a collection page
201
+ let collectionEntry: CollectionEntry | undefined
202
+ if (collectionInfo && mdContent) {
203
+ collectionEntry = {
204
+ collectionName: mdContent.collectionName,
205
+ collectionSlug: mdContent.collectionSlug,
206
+ sourcePath: mdContent.file,
207
+ frontmatter: mdContent.frontmatter,
208
+ body: mdContent.body,
209
+ bodyStartLine: mdContent.bodyStartLine,
210
+ wrapperId: result.collectionWrapperId,
211
+ }
212
+ }
213
+
176
214
  // In dev mode, we use the source info from Astro compiler attributes
177
215
  // which is already extracted by html-processor, so no need to call findSourceLocation
178
216
  return {
179
217
  html: result.html,
180
218
  entries: result.entries,
181
219
  components: result.components,
220
+ collection: collectionEntry,
182
221
  }
183
222
  }
@@ -11,12 +11,23 @@ export interface ProcessHtmlOptions {
11
11
  componentDirs?: string[]
12
12
  excludeComponentDirs?: string[]
13
13
  markStyledSpans?: boolean
14
+ /** When true, only mark elements that have source file attributes (from Astro templates) */
15
+ skipMarkdownContent?: boolean
16
+ /** Collection info for marking the wrapper element containing markdown content */
17
+ collectionInfo?: {
18
+ name: string
19
+ slug: string
20
+ /** First line of the markdown body (used to find wrapper element in build mode) */
21
+ bodyFirstLine?: string
22
+ }
14
23
  }
15
24
 
16
25
  export interface ProcessHtmlResult {
17
26
  html: string
18
27
  entries: Record<string, ManifestEntry>
19
28
  components: Record<string, ComponentInstance>
29
+ /** ID of the element wrapping collection markdown content */
30
+ collectionWrapperId?: string
20
31
  }
21
32
 
22
33
  /**
@@ -116,6 +127,8 @@ export async function processHtml(
116
127
  componentDirs = ['src/components'],
117
128
  excludeComponentDirs = ['src/pages', 'src/layouts', 'src/layout'],
118
129
  markStyledSpans = true,
130
+ skipMarkdownContent = false,
131
+ collectionInfo,
119
132
  } = options
120
133
 
121
134
  const root = parse(html, {
@@ -133,6 +146,7 @@ export async function processHtml(
133
146
  const components: Record<string, ComponentInstance> = {}
134
147
  const sourceLocationMap = new Map<string, { file: string; line: number }>()
135
148
  const markedComponentRoots = new Set<any>()
149
+ let collectionWrapperId: string | undefined
136
150
 
137
151
  // First pass: detect and mark component root elements
138
152
  // A component root is detected by data-astro-source-file pointing to a component directory
@@ -187,7 +201,10 @@ export async function processHtml(
187
201
  // Extract component name from file path (e.g., "src/components/Welcome.astro" -> "Welcome")
188
202
  const componentName = extractComponentName(sourceFile)
189
203
  // Parse source loc - format is "line:col" e.g. "20:21"
190
- const sourceLocAttr = node.getAttribute('data-astro-source-line') || '1:0'
204
+ // Support both our custom attribute and Astro's native attribute
205
+ const sourceLocAttr = node.getAttribute('data-astro-source-loc')
206
+ || node.getAttribute('data-astro-source-line')
207
+ || '1:0'
191
208
  const sourceLine = parseInt(sourceLocAttr.split(':')[0] ?? '1', 10)
192
209
 
193
210
  components[id] = {
@@ -218,6 +235,112 @@ export async function processHtml(
218
235
  })
219
236
  }
220
237
 
238
+ // Collection wrapper detection pass: find the element that wraps markdown content
239
+ // Two strategies:
240
+ // 1. Dev mode: look for elements with data-astro-source-file containing children without it
241
+ // 2. Build mode: find element whose first child content matches the start of markdown body
242
+ if (collectionInfo) {
243
+ const allElements = root.querySelectorAll('*')
244
+ let foundWrapper = false
245
+
246
+ // Strategy 1: Dev mode - look for source file attributes
247
+ for (const node of allElements) {
248
+ const sourceFile = node.getAttribute('data-astro-source-file')
249
+ if (!sourceFile) continue
250
+
251
+ // Check if this element has any direct child elements without source file attribute
252
+ // These would be markdown-rendered elements
253
+ const childElements = node.childNodes.filter(
254
+ (child: any) => child.nodeType === 1 && child.tagName,
255
+ )
256
+ const hasMarkdownChildren = childElements.some(
257
+ (child: any) => !child.getAttribute?.('data-astro-source-file'),
258
+ )
259
+
260
+ if (hasMarkdownChildren) {
261
+ // Check if any ancestor already has been marked as a collection wrapper
262
+ // We want the innermost wrapper
263
+ let parent = node.parentNode
264
+ let hasAncestorWrapper = false
265
+ while (parent) {
266
+ if ((parent as any).getAttribute?.(attributeName)?.startsWith('cms-collection-')) {
267
+ hasAncestorWrapper = true
268
+ break
269
+ }
270
+ parent = parent.parentNode
271
+ }
272
+
273
+ if (!hasAncestorWrapper) {
274
+ // Mark this as the collection wrapper using the standard attribute
275
+ const id = getNextId()
276
+ node.setAttribute(attributeName, id)
277
+ collectionWrapperId = id
278
+ foundWrapper = true
279
+ // Don't break - we want the deepest wrapper, so we'll overwrite
280
+ }
281
+ }
282
+ }
283
+
284
+ // Strategy 2: Build mode - find element by matching markdown body content
285
+ if (!foundWrapper && collectionInfo.bodyFirstLine) {
286
+ // Normalize the first line of markdown body for comparison
287
+ // Strip markdown syntax to compare with rendered HTML text
288
+ const bodyStart = collectionInfo.bodyFirstLine
289
+ .replace(/^\*\*|\*\*$/g, '') // Remove markdown bold markers at start/end
290
+ .replace(/\*\*/g, '') // Remove any remaining markdown bold markers
291
+ .replace(/\*/g, '') // Remove markdown italic markers
292
+ .replace(/^#+ /, '') // Remove heading markers
293
+ .replace(/^\s*[-*+]\s+/, '') // Remove list markers
294
+ .replace(/\[([^\]]+)\]\([^)]+\)/g, '$1') // Extract link text
295
+ .trim()
296
+ .substring(0, 50) // Take first 50 chars for matching
297
+
298
+ if (bodyStart.length > 10) {
299
+ // Store all candidates that match the body start
300
+ const candidates: Array<{ node: any; blockChildCount: number }> = []
301
+
302
+ for (const node of allElements) {
303
+ const tag = node.tagName?.toLowerCase?.() ?? ''
304
+ // Skip script, style, etc.
305
+ if (['script', 'style', 'head', 'meta', 'link'].includes(tag)) continue
306
+
307
+ // Check if this element's first text content starts with the markdown body
308
+ const firstChild = node.childNodes.find(
309
+ (child: any) => child.nodeType === 1 && child.tagName,
310
+ ) as any
311
+
312
+ if (firstChild) {
313
+ const firstChildText = (firstChild.innerText || '').trim().substring(0, 80)
314
+ if (firstChildText.includes(bodyStart)) {
315
+ // Count block-level child elements
316
+ // Markdown typically renders to multiple block elements (p, h2, h3, ul, ol, etc.)
317
+ const blockTags = ['p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'ul', 'ol', 'blockquote', 'pre', 'table', 'hr']
318
+ const blockChildCount = node.childNodes.filter(
319
+ (child: any) => child.nodeType === 1 && blockTags.includes(child.tagName?.toLowerCase?.()),
320
+ ).length
321
+
322
+ candidates.push({ node, blockChildCount })
323
+ }
324
+ }
325
+ }
326
+
327
+ // Pick the candidate with the most block children (likely the markdown wrapper)
328
+ // Filter out already-marked elements
329
+ const unmarkedCandidates = candidates.filter(c => !c.node.getAttribute(attributeName))
330
+ if (unmarkedCandidates.length > 0) {
331
+ const best = unmarkedCandidates.reduce((a, b) => (b.blockChildCount > a.blockChildCount ? b : a))
332
+ if (best.blockChildCount >= 2) {
333
+ // Markdown body should have at least 2 block children
334
+ const id = getNextId()
335
+ best.node.setAttribute(attributeName, id)
336
+ collectionWrapperId = id
337
+ foundWrapper = true
338
+ }
339
+ }
340
+ }
341
+ }
342
+ }
343
+
221
344
  // Third pass: assign IDs to all qualifying text elements and extract source locations
222
345
  root.querySelectorAll('*').forEach((node) => {
223
346
  const tag = node.tagName?.toLowerCase?.() ?? ''
@@ -229,12 +352,20 @@ export async function processHtml(
229
352
  const textContent = (node.innerText ?? '').trim()
230
353
  if (!includeEmptyText && !textContent) return
231
354
 
232
- const id = getNextId()
233
- node.setAttribute(attributeName, id)
234
-
235
355
  // Extract source location from Astro compiler attributes
356
+ // Support both Astro's native attribute (data-astro-source-loc) and our custom one (data-astro-source-line)
236
357
  const sourceFile = node.getAttribute('data-astro-source-file')
237
- const sourceLine = node.getAttribute('data-astro-source-line')
358
+ const sourceLine = node.getAttribute('data-astro-source-loc')
359
+ || node.getAttribute('data-astro-source-line')
360
+
361
+ // When skipMarkdownContent is true, only mark elements that have source file attributes
362
+ // (meaning they come from Astro templates, not rendered markdown content)
363
+ if (skipMarkdownContent && !sourceFile) {
364
+ return
365
+ }
366
+
367
+ const id = getNextId()
368
+ node.setAttribute(attributeName, id)
238
369
 
239
370
  if (sourceFile && sourceLine) {
240
371
  const lineNum = parseInt(sourceLine.split(':')[0] ?? '1', 10)
@@ -245,6 +376,7 @@ export async function processHtml(
245
376
  // Component roots need these for identification
246
377
  if (!markedComponentRoots.has(node)) {
247
378
  node.removeAttribute('data-astro-source-file')
379
+ node.removeAttribute('data-astro-source-loc')
248
380
  node.removeAttribute('data-astro-source-line')
249
381
  }
250
382
  }
@@ -291,8 +423,12 @@ export async function processHtml(
291
423
  // Get direct text content (without placeholders)
292
424
  const directText = textWithPlaceholders.replace(/\{\{cms:[^}]+\}\}/g, '').trim()
293
425
 
426
+ // Check if this is the collection wrapper
427
+ const isCollectionWrapper = id === collectionWrapperId
428
+
294
429
  // Skip pure container elements (no direct text, only child CMS elements)
295
- if (!directText && childCmsIds.length > 0) {
430
+ // BUT always include the collection wrapper
431
+ if (!directText && childCmsIds.length > 0 && !isCollectionWrapper) {
296
432
  return
297
433
  }
298
434
 
@@ -313,16 +449,18 @@ export async function processHtml(
313
449
 
314
450
  entries[id] = {
315
451
  id,
316
- file: fileId,
317
452
  tag,
318
453
  text: textWithPlaceholders.trim(),
319
454
  sourcePath: sourceLocation?.file || sourcePath,
320
455
  childCmsIds: childCmsIds.length > 0 ? childCmsIds : undefined,
321
456
  sourceLine: sourceLocation?.line,
322
457
  sourceSnippet: undefined,
323
- sourceType: undefined,
458
+ sourceType: isCollectionWrapper ? 'collection' : undefined,
324
459
  variableName: undefined,
325
460
  parentComponentId,
461
+ // Add collection info for the wrapper entry
462
+ collectionName: isCollectionWrapper ? collectionInfo?.name : undefined,
463
+ collectionSlug: isCollectionWrapper ? collectionInfo?.slug : undefined,
326
464
  }
327
465
  })
328
466
  }
@@ -330,6 +468,7 @@ export async function processHtml(
330
468
  // Clean up any remaining source attributes from component-marked elements
331
469
  markedComponentRoots.forEach((node: any) => {
332
470
  node.removeAttribute('data-astro-source-file')
471
+ node.removeAttribute('data-astro-source-loc')
333
472
  node.removeAttribute('data-astro-source-line')
334
473
  })
335
474
 
@@ -337,6 +476,7 @@ export async function processHtml(
337
476
  html: root.toString(),
338
477
  entries,
339
478
  components,
479
+ collectionWrapperId,
340
480
  }
341
481
  }
342
482
 
package/src/index.ts CHANGED
@@ -16,6 +16,7 @@ export default function cmsMarker(options: CmsMarkerOptions = {}): AstroIntegrat
16
16
  manifestFile = 'cms-manifest.json',
17
17
  markComponents = true,
18
18
  componentDirs = ['src/components'],
19
+ contentDir = 'src/content',
19
20
  } = options
20
21
 
21
22
  let componentDefinitions: Record<string, ComponentDefinition> = {}
@@ -35,6 +36,7 @@ export default function cmsMarker(options: CmsMarkerOptions = {}): AstroIntegrat
35
36
  manifestFile,
36
37
  markComponents,
37
38
  componentDirs,
39
+ contentDir,
38
40
  }
39
41
 
40
42
  return {
@@ -89,4 +91,7 @@ export default function cmsMarker(options: CmsMarkerOptions = {}): AstroIntegrat
89
91
  }
90
92
 
91
93
  // Re-export types for consumers
92
- export type { CmsManifest, CmsMarkerOptions, ComponentDefinition, ComponentInstance, ManifestEntry } from './types'
94
+ export { findCollectionSource, parseMarkdownContent } from './source-finder'
95
+ export type { CollectionInfo, MarkdownContent } from './source-finder'
96
+ export type { CmsManifest, CmsMarkerOptions, CollectionEntry, ComponentDefinition, ComponentInstance, ManifestEntry } from './types'
97
+
@@ -1,6 +1,6 @@
1
1
  import fs from 'node:fs/promises'
2
2
  import path from 'node:path'
3
- import type { CmsManifest, ComponentDefinition, ComponentInstance, ManifestEntry } from './types'
3
+ import type { CmsManifest, CollectionEntry, ComponentDefinition, ComponentInstance, ManifestEntry } from './types'
4
4
 
5
5
  /**
6
6
  * Manages streaming manifest writes during build.
@@ -8,19 +8,24 @@ import type { CmsManifest, ComponentDefinition, ComponentInstance, ManifestEntry
8
8
  */
9
9
  export class ManifestWriter {
10
10
  private globalManifest: CmsManifest
11
- private pageManifests: Map<string, { entries: Record<string, ManifestEntry>; components: Record<string, ComponentInstance> }> = new Map()
11
+ private pageManifests: Map<string, {
12
+ entries: Record<string, ManifestEntry>
13
+ components: Record<string, ComponentInstance>
14
+ collection?: CollectionEntry
15
+ }> = new Map()
12
16
  private outDir: string = ''
13
17
  private manifestFile: string
14
18
  private componentDefinitions: Record<string, ComponentDefinition>
15
19
  private writeQueue: Promise<void> = Promise.resolve()
16
20
 
17
- constructor(manifestFile: string = 'cms-manifest.json', componentDefinitions: Record<string, ComponentDefinition> = {}) {
21
+ constructor(manifestFile: string, componentDefinitions: Record<string, ComponentDefinition> = {}) {
18
22
  this.manifestFile = manifestFile
19
23
  this.componentDefinitions = componentDefinitions
20
24
  this.globalManifest = {
21
25
  entries: {},
22
26
  components: {},
23
27
  componentDefinitions,
28
+ collections: {},
24
29
  }
25
30
  }
26
31
 
@@ -59,17 +64,25 @@ export class ManifestWriter {
59
64
  pagePath: string,
60
65
  entries: Record<string, ManifestEntry>,
61
66
  components: Record<string, ComponentInstance>,
67
+ collection?: CollectionEntry,
62
68
  ): void {
63
69
  // Store in memory
64
- this.pageManifests.set(pagePath, { entries, components })
70
+ this.pageManifests.set(pagePath, { entries, components, collection })
65
71
 
66
72
  // Update global manifest
67
73
  Object.assign(this.globalManifest.entries, entries)
68
74
  Object.assign(this.globalManifest.components, components)
69
75
 
76
+ // Add collection entry to global manifest
77
+ if (collection) {
78
+ const collectionKey = `${collection.collectionName}/${collection.collectionSlug}`
79
+ this.globalManifest.collections = this.globalManifest.collections || {}
80
+ this.globalManifest.collections[collectionKey] = collection
81
+ }
82
+
70
83
  // Queue the write operation (non-blocking)
71
84
  if (this.outDir) {
72
- this.writeQueue = this.writeQueue.then(() => this.writePageManifest(pagePath, entries, components))
85
+ this.writeQueue = this.writeQueue.then(() => this.writePageManifest(pagePath, entries, components, collection))
73
86
  }
74
87
  }
75
88
 
@@ -80,19 +93,30 @@ export class ManifestWriter {
80
93
  pagePath: string,
81
94
  entries: Record<string, ManifestEntry>,
82
95
  components: Record<string, ComponentInstance>,
96
+ collection?: CollectionEntry,
83
97
  ): Promise<void> {
84
98
  const manifestPath = this.getPageManifestPath(pagePath)
85
99
  const manifestDir = path.dirname(manifestPath)
86
100
 
87
101
  await fs.mkdir(manifestDir, { recursive: true })
88
102
 
89
- const pageManifest = {
103
+ const pageManifest: {
104
+ page: string
105
+ entries: Record<string, ManifestEntry>
106
+ components: Record<string, ComponentInstance>
107
+ componentDefinitions: Record<string, ComponentDefinition>
108
+ collection?: CollectionEntry
109
+ } = {
90
110
  page: pagePath,
91
111
  entries,
92
112
  components,
93
113
  componentDefinitions: this.componentDefinitions,
94
114
  }
95
115
 
116
+ if (collection) {
117
+ pageManifest.collection = collection
118
+ }
119
+
96
120
  await fs.writeFile(manifestPath, JSON.stringify(pageManifest, null, 2), 'utf-8')
97
121
  }
98
122
 
@@ -134,7 +158,11 @@ export class ManifestWriter {
134
158
  /**
135
159
  * Get a page's manifest data (for dev mode)
136
160
  */
137
- getPageManifest(pagePath: string): { entries: Record<string, ManifestEntry>; components: Record<string, ComponentInstance> } | undefined {
161
+ getPageManifest(pagePath: string): {
162
+ entries: Record<string, ManifestEntry>
163
+ components: Record<string, ComponentInstance>
164
+ collection?: CollectionEntry
165
+ } | undefined {
138
166
  return this.pageManifests.get(pagePath)
139
167
  }
140
168
 
@@ -147,6 +175,7 @@ export class ManifestWriter {
147
175
  entries: {},
148
176
  components: {},
149
177
  componentDefinitions: this.componentDefinitions,
178
+ collections: {},
150
179
  }
151
180
  this.writeQueue = Promise.resolve()
152
181
  }
@@ -5,9 +5,13 @@ export interface SourceLocation {
5
5
  file: string
6
6
  line: number
7
7
  snippet?: string
8
- type?: 'static' | 'variable' | 'prop' | 'computed'
8
+ type?: 'static' | 'variable' | 'prop' | 'computed' | 'collection'
9
9
  variableName?: string
10
10
  definitionLine?: number
11
+ /** Collection name for collection entries */
12
+ collectionName?: string
13
+ /** Entry slug for collection entries */
14
+ collectionSlug?: string
11
15
  }
12
16
 
13
17
  export interface VariableReference {
@@ -16,6 +20,27 @@ export interface VariableReference {
16
20
  definitionLine: number
17
21
  }
18
22
 
23
+ export interface CollectionInfo {
24
+ name: string
25
+ slug: string
26
+ file: string
27
+ }
28
+
29
+ export interface MarkdownContent {
30
+ /** Frontmatter fields as key-value pairs with line numbers */
31
+ frontmatter: Record<string, { value: string; line: number }>
32
+ /** The full markdown body content */
33
+ body: string
34
+ /** Line number where body starts */
35
+ bodyStartLine: number
36
+ /** File path relative to cwd */
37
+ file: string
38
+ /** Collection name */
39
+ collectionName: string
40
+ /** Collection slug */
41
+ collectionSlug: string
42
+ }
43
+
19
44
  /**
20
45
  * Find source file and line number for text content
21
46
  */
@@ -46,7 +71,7 @@ export async function findSourceLocation(
46
71
  // If not found directly, try searching for prop values in parent components
47
72
  for (const dir of searchDirs) {
48
73
  try {
49
- const result = await searchForPropInParents(dir, textContent, tag)
74
+ const result = await searchForPropInParents(dir, textContent)
50
75
  if (result) {
51
76
  return result
52
77
  }
@@ -102,7 +127,7 @@ async function searchAstroFile(
102
127
  const content = await fs.readFile(filePath, 'utf-8')
103
128
  const lines = content.split('\n')
104
129
 
105
- const cleanText = cleanTextForSearch(textContent)
130
+ const cleanText = normalizeText(textContent)
106
131
  const textPreview = cleanText.slice(0, Math.min(30, cleanText.length))
107
132
 
108
133
  // Extract variable references from frontmatter
@@ -217,113 +242,103 @@ async function searchAstroFile(
217
242
  /**
218
243
  * Search for prop values passed to components
219
244
  */
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
245
+ async function searchForPropInParents(dir: string, textContent: string): Promise<SourceLocation | undefined> {
246
+ const entries = await fs.readdir(dir, { withFileTypes: true })
247
+ const cleanText = normalizeText(textContent)
248
+
249
+ for (const entry of entries) {
250
+ const fullPath = path.join(dir, entry.name)
251
+
252
+ if (entry.isDirectory()) {
253
+ const result = await searchForPropInParents(fullPath, textContent)
254
+ if (result) return result
255
+ } else if (entry.isFile() && entry.name.endsWith('.astro')) {
256
+ const content = await fs.readFile(fullPath, 'utf-8')
257
+ const lines = content.split('\n')
258
+
259
+ // Look for component tags with prop values matching our text
260
+ for (let i = 0; i < lines.length; i++) {
261
+ const line = lines[i]
262
+
263
+ // Match component usage like <ComponentName propName="value" />
264
+ const componentMatch = line?.match(/<([A-Z]\w+)/)
265
+ if (!componentMatch) continue
266
+
267
+ // Collect only the opening tag (until first > or />), not nested content
268
+ let openingTag = ''
269
+ let endLine = i
270
+ for (let j = i; j < Math.min(i + 10, lines.length); j++) {
271
+ openingTag += ' ' + lines[j]
272
+ endLine = j
273
+
274
+ // Stop at the end of opening tag (either /> or >)
275
+ if (lines[j]?.includes('/>')) {
276
+ // Self-closing tag
277
+ break
278
+ } else if (lines[j]?.includes('>')) {
279
+ // Opening tag ends here, don't include nested content
280
+ // Truncate to just the opening tag part
281
+ const tagEndIndex = openingTag.indexOf('>')
282
+ if (tagEndIndex !== -1) {
283
+ openingTag = openingTag.substring(0, tagEndIndex + 1)
266
284
  }
285
+ break
267
286
  }
287
+ }
268
288
 
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
- }
289
+ // Extract all prop values from the opening tag only
290
+ const propMatches = openingTag.matchAll(/(\w+)=["']([^"']+)["']/g)
291
+ for (const match of propMatches) {
292
+ const propName = match[1]
293
+ const propValue = match[2]
278
294
 
279
- const normalizedValue = normalizeText(propValue)
295
+ if (!propValue) {
296
+ continue
297
+ }
280
298
 
281
- if (normalizedValue === cleanText) {
282
- // Find which line actually contains this prop
283
- let propLine = i
284
- let propLineIndex = i
299
+ const normalizedValue = normalizeText(propValue)
285
300
 
286
- for (let k = i; k <= endLine; k++) {
287
- const line = lines[k]
288
- if (!line) {
289
- continue
290
- }
301
+ if (normalizedValue === cleanText) {
302
+ // Find which line actually contains this prop
303
+ let propLine = i
291
304
 
292
- if (propName && line.includes(propName) && line.includes(propValue)) {
293
- propLine = k
294
- propLineIndex = k
295
- break
296
- }
305
+ for (let k = i; k <= endLine; k++) {
306
+ const line = lines[k]
307
+ if (!line) {
308
+ continue
297
309
  }
298
310
 
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
- }
311
+ if (propName && line.includes(propName) && line.includes(propValue)) {
312
+ propLine = k
313
+ break
314
+ }
315
+ }
306
316
 
307
- componentSnippetLines.push(line)
317
+ // Extract complete component tag starting from where the component tag opens
318
+ const componentSnippetLines: string[] = []
319
+ for (let k = i; k <= endLine; k++) {
320
+ const line = lines[k]
321
+ if (!line) {
322
+ continue
308
323
  }
309
324
 
310
- const propSnippet = componentSnippetLines.join('\n')
325
+ componentSnippetLines.push(line)
326
+ }
311
327
 
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
- }
328
+ const propSnippet = componentSnippetLines.join('\n')
329
+
330
+ // Found the prop being passed with our text value
331
+ return {
332
+ file: path.relative(process.cwd(), fullPath),
333
+ line: propLine + 1,
334
+ snippet: propSnippet,
335
+ type: 'prop',
336
+ variableName: propName,
320
337
  }
321
338
  }
322
339
  }
323
340
  }
324
341
  }
325
- } catch {
326
- // Error reading directory
327
342
  }
328
343
 
329
344
  return undefined
@@ -468,8 +483,289 @@ function normalizeText(text: string): string {
468
483
  }
469
484
 
470
485
  /**
471
- * Clean text for search comparison
486
+ * Find markdown collection file for a given page path
487
+ * @param pagePath - The URL path of the page (e.g., '/services/3d-tisk')
488
+ * @param contentDir - The content directory (default: 'src/content')
489
+ * @returns Collection info if found, undefined otherwise
490
+ */
491
+ export async function findCollectionSource(
492
+ pagePath: string,
493
+ contentDir: string = 'src/content',
494
+ ): Promise<CollectionInfo | undefined> {
495
+ // Remove leading/trailing slashes
496
+ const cleanPath = pagePath.replace(/^\/+|\/+$/g, '')
497
+ const pathParts = cleanPath.split('/')
498
+
499
+ if (pathParts.length < 2) {
500
+ // Need at least collection/slug
501
+ return undefined
502
+ }
503
+
504
+ const contentPath = path.join(process.cwd(), contentDir)
505
+
506
+ try {
507
+ // Check if content directory exists
508
+ await fs.access(contentPath)
509
+ } catch {
510
+ return undefined
511
+ }
512
+
513
+ // Try different collection/slug combinations
514
+ // Strategy 1: First segment is collection, rest is slug
515
+ // e.g., /services/3d-tisk -> collection: services, slug: 3d-tisk
516
+ const collectionName = pathParts[0]
517
+ const slug = pathParts.slice(1).join('/')
518
+
519
+ if (!collectionName || !slug) {
520
+ return undefined
521
+ }
522
+
523
+ const collectionPath = path.join(contentPath, collectionName)
524
+
525
+ try {
526
+ await fs.access(collectionPath)
527
+ const stat = await fs.stat(collectionPath)
528
+ if (!stat.isDirectory()) {
529
+ return undefined
530
+ }
531
+ } catch {
532
+ return undefined
533
+ }
534
+
535
+ // Look for markdown files matching the slug
536
+ const mdFile = await findMarkdownFile(collectionPath, slug)
537
+ if (mdFile) {
538
+ return {
539
+ name: collectionName,
540
+ slug,
541
+ file: path.relative(process.cwd(), mdFile),
542
+ }
543
+ }
544
+
545
+ return undefined
546
+ }
547
+
548
+ /**
549
+ * Find a markdown file in a collection directory by slug
550
+ */
551
+ async function findMarkdownFile(collectionPath: string, slug: string): Promise<string | undefined> {
552
+ // Try direct match: slug.md or slug.mdx
553
+ const directPaths = [
554
+ path.join(collectionPath, `${slug}.md`),
555
+ path.join(collectionPath, `${slug}.mdx`),
556
+ ]
557
+
558
+ for (const p of directPaths) {
559
+ try {
560
+ await fs.access(p)
561
+ return p
562
+ } catch {
563
+ // File doesn't exist, continue
564
+ }
565
+ }
566
+
567
+ // Try nested path for slugs with slashes
568
+ const slugParts = slug.split('/')
569
+ if (slugParts.length > 1) {
570
+ const nestedPath = path.join(collectionPath, ...slugParts.slice(0, -1))
571
+ const fileName = slugParts[slugParts.length - 1]
572
+ const nestedPaths = [
573
+ path.join(nestedPath, `${fileName}.md`),
574
+ path.join(nestedPath, `${fileName}.mdx`),
575
+ ]
576
+ for (const p of nestedPaths) {
577
+ try {
578
+ await fs.access(p)
579
+ return p
580
+ } catch {
581
+ // File doesn't exist, continue
582
+ }
583
+ }
584
+ }
585
+
586
+ // Try index file in slug directory
587
+ const indexPaths = [
588
+ path.join(collectionPath, slug, 'index.md'),
589
+ path.join(collectionPath, slug, 'index.mdx'),
590
+ ]
591
+
592
+ for (const p of indexPaths) {
593
+ try {
594
+ await fs.access(p)
595
+ return p
596
+ } catch {
597
+ // File doesn't exist, continue
598
+ }
599
+ }
600
+
601
+ return undefined
602
+ }
603
+
604
+ /**
605
+ * Find text content in a markdown file and return source location
606
+ * Only matches frontmatter fields, not body content (body is handled separately as a whole)
607
+ * @param textContent - The text content to search for
608
+ * @param collectionInfo - Collection information (name, slug, file path)
609
+ * @returns Source location if found in frontmatter
472
610
  */
473
- function cleanTextForSearch(text: string): string {
474
- return normalizeText(text)
611
+ export async function findMarkdownSourceLocation(
612
+ textContent: string,
613
+ collectionInfo: CollectionInfo,
614
+ ): Promise<SourceLocation | undefined> {
615
+ try {
616
+ const filePath = path.join(process.cwd(), collectionInfo.file)
617
+ const content = await fs.readFile(filePath, 'utf-8')
618
+ const lines = content.split('\n')
619
+ const normalizedSearch = normalizeText(textContent)
620
+
621
+ // Parse frontmatter
622
+ let frontmatterEnd = -1
623
+ let inFrontmatter = false
624
+
625
+ for (let i = 0; i < lines.length; i++) {
626
+ const line = lines[i]?.trim()
627
+ if (line === '---') {
628
+ if (!inFrontmatter) {
629
+ inFrontmatter = true
630
+ } else {
631
+ frontmatterEnd = i
632
+ break
633
+ }
634
+ }
635
+ }
636
+
637
+ // Search in frontmatter only (for title, subtitle, etc.)
638
+ if (frontmatterEnd > 0) {
639
+ for (let i = 1; i < frontmatterEnd; i++) {
640
+ const line = lines[i]
641
+ if (!line) continue
642
+
643
+ // Extract value from YAML key: value
644
+ const match = line.match(/^\s*(\w+):\s*(.+)$/)
645
+ if (match) {
646
+ const key = match[1]
647
+ let value = match[2]?.trim() || ''
648
+
649
+ // Handle quoted strings
650
+ if ((value.startsWith('"') && value.endsWith('"')) ||
651
+ (value.startsWith("'") && value.endsWith("'"))) {
652
+ value = value.slice(1, -1)
653
+ }
654
+
655
+ if (normalizeText(value) === normalizedSearch) {
656
+ return {
657
+ file: collectionInfo.file,
658
+ line: i + 1,
659
+ snippet: line,
660
+ type: 'collection',
661
+ variableName: key,
662
+ collectionName: collectionInfo.name,
663
+ collectionSlug: collectionInfo.slug,
664
+ }
665
+ }
666
+ }
667
+ }
668
+ }
669
+
670
+ // Body content is not searched line-by-line anymore
671
+ // Use parseMarkdownContent to get the full body as one entry
672
+ } catch {
673
+ // Error reading file
674
+ }
675
+
676
+ return undefined
475
677
  }
678
+
679
+ /**
680
+ * Parse markdown file and extract frontmatter fields and full body content
681
+ * @param collectionInfo - Collection information (name, slug, file path)
682
+ * @returns Parsed markdown content with frontmatter and body
683
+ */
684
+ export async function parseMarkdownContent(
685
+ collectionInfo: CollectionInfo,
686
+ ): Promise<MarkdownContent | undefined> {
687
+ try {
688
+ const filePath = path.join(process.cwd(), collectionInfo.file)
689
+ const content = await fs.readFile(filePath, 'utf-8')
690
+ const lines = content.split('\n')
691
+
692
+ // Parse frontmatter
693
+ let frontmatterStart = -1
694
+ let frontmatterEnd = -1
695
+
696
+ for (let i = 0; i < lines.length; i++) {
697
+ const line = lines[i]?.trim()
698
+ if (line === '---') {
699
+ if (frontmatterStart === -1) {
700
+ frontmatterStart = i
701
+ } else {
702
+ frontmatterEnd = i
703
+ break
704
+ }
705
+ }
706
+ }
707
+
708
+ const frontmatter: Record<string, { value: string; line: number }> = {}
709
+
710
+ // Extract frontmatter fields
711
+ if (frontmatterEnd > 0) {
712
+ for (let i = frontmatterStart + 1; i < frontmatterEnd; i++) {
713
+ const line = lines[i]
714
+ if (!line) continue
715
+
716
+ // Extract value from YAML key: value (simple single-line values only)
717
+ const match = line.match(/^\s*(\w+):\s*(.+)$/)
718
+ if (match) {
719
+ const key = match[1]
720
+ let value = match[2]?.trim() || ''
721
+
722
+ // Handle quoted strings
723
+ if ((value.startsWith('"') && value.endsWith('"')) ||
724
+ (value.startsWith("'") && value.endsWith("'"))) {
725
+ value = value.slice(1, -1)
726
+ }
727
+
728
+ if (key && value) {
729
+ frontmatter[key] = { value, line: i + 1 }
730
+ }
731
+ }
732
+ }
733
+ }
734
+
735
+ // Extract body (everything after frontmatter)
736
+ const bodyStartLine = frontmatterEnd > 0 ? frontmatterEnd + 1 : 0
737
+ const bodyLines = lines.slice(bodyStartLine)
738
+ const body = bodyLines.join('\n').trim()
739
+
740
+ return {
741
+ frontmatter,
742
+ body,
743
+ bodyStartLine: bodyStartLine + 1, // 1-indexed
744
+ file: collectionInfo.file,
745
+ collectionName: collectionInfo.name,
746
+ collectionSlug: collectionInfo.slug,
747
+ }
748
+ } catch {
749
+ // Error reading file
750
+ }
751
+
752
+ return undefined
753
+ }
754
+
755
+ /**
756
+ * Strip markdown syntax for text comparison
757
+ */
758
+ function stripMarkdownSyntax(text: string): string {
759
+ return text
760
+ .replace(/^#+\s+/, '') // Headers
761
+ .replace(/\*\*([^*]+)\*\*/g, '$1') // Bold
762
+ .replace(/\*([^*]+)\*/g, '$1') // Italic
763
+ .replace(/__([^_]+)__/g, '$1') // Bold (underscore)
764
+ .replace(/_([^_]+)_/g, '$1') // Italic (underscore)
765
+ .replace(/`([^`]+)`/g, '$1') // Inline code
766
+ .replace(/\[([^\]]+)\]\([^)]+\)/g, '$1') // Links
767
+ .replace(/^\s*[-*+]\s+/, '') // List items
768
+ .replace(/^\s*\d+\.\s+/, '') // Numbered lists
769
+ .trim()
770
+ }
771
+
package/src/types.ts CHANGED
@@ -7,6 +7,8 @@ export interface CmsMarkerOptions {
7
7
  manifestFile?: string
8
8
  markComponents?: boolean
9
9
  componentDirs?: string[]
10
+ /** Directory containing content collections (default: 'src/content') */
11
+ contentDir?: string
10
12
  }
11
13
 
12
14
  export interface ComponentProp {
@@ -27,16 +29,19 @@ export interface ComponentDefinition {
27
29
 
28
30
  export interface ManifestEntry {
29
31
  id: string
30
- file: string
31
32
  tag: string
32
33
  text: string
33
34
  sourcePath?: string
34
35
  sourceLine?: number
35
36
  sourceSnippet?: string
36
- sourceType?: 'static' | 'variable' | 'prop' | 'computed'
37
+ sourceType?: 'static' | 'variable' | 'prop' | 'computed' | 'collection'
37
38
  variableName?: string
38
39
  childCmsIds?: string[]
39
40
  parentComponentId?: string
41
+ /** Collection name for collection entries (e.g., 'services', 'blog') */
42
+ collectionName?: string
43
+ /** Entry slug for collection entries (e.g., '3d-tisk') */
44
+ collectionSlug?: string
40
45
  }
41
46
 
42
47
  export interface ComponentInstance {
@@ -50,8 +55,28 @@ export interface ComponentInstance {
50
55
  parentId?: string
51
56
  }
52
57
 
58
+ /** Represents a content collection entry (markdown file) */
59
+ export interface CollectionEntry {
60
+ /** Collection name (e.g., 'services', 'blog') */
61
+ collectionName: string
62
+ /** Entry slug (e.g., '3d-tisk') */
63
+ collectionSlug: string
64
+ /** Path to the markdown file relative to project root */
65
+ sourcePath: string
66
+ /** Frontmatter fields with their values and line numbers */
67
+ frontmatter: Record<string, { value: string; line: number }>
68
+ /** Full markdown body content */
69
+ body: string
70
+ /** Line number where body starts (1-indexed) */
71
+ bodyStartLine: number
72
+ /** ID of the wrapper element containing the rendered markdown */
73
+ wrapperId?: string
74
+ }
75
+
53
76
  export interface CmsManifest {
54
77
  entries: Record<string, ManifestEntry>
55
78
  components: Record<string, ComponentInstance>
56
79
  componentDefinitions: Record<string, ComponentDefinition>
80
+ /** Content collection entries indexed by "collectionName/slug" */
81
+ collections?: Record<string, CollectionEntry>
57
82
  }
@@ -1,7 +1,7 @@
1
1
  import type { Plugin } from 'vite'
2
+ import { createAstroTransformPlugin } from './astro-transform'
2
3
  import type { ManifestWriter } from './manifest-writer'
3
4
  import type { CmsMarkerOptions, ComponentDefinition } from './types'
4
- import { createAstroTransformPlugin } from './astro-transform'
5
5
 
6
6
  export interface VitePluginContext {
7
7
  manifestWriter: ManifestWriter
@@ -35,11 +35,12 @@ export function createVitePlugin(context: VitePluginContext): Plugin[] {
35
35
  }
36
36
 
37
37
  // Create the Astro transform plugin to inject source location attributes
38
- // Only enabled in dev mode - during build, source locations are handled
39
- // in build-processor.ts after HTML is generated
38
+ // NOTE: Disabled - Astro's native compiler already injects source location
39
+ // attributes (data-astro-source-file, data-astro-source-loc) in dev mode.
40
+ // Our html-processor recognizes these native attributes automatically.
40
41
  const astroTransformPlugin = createAstroTransformPlugin({
41
42
  markComponents: config.markComponents,
42
- enabled: command === 'dev',
43
+ enabled: false, // Not needed - Astro provides source attributes natively
43
44
  })
44
45
 
45
46
  // Note: We cannot use transformIndexHtml for static Astro builds because