@nuasite/cms-marker 0.0.47 → 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 +2 -2
- package/src/astro-transform.ts +10 -10
- package/src/build-processor.ts +77 -4
- package/src/component-registry.ts +7 -7
- package/src/dev-middleware.ts +42 -3
- package/src/html-processor.ts +148 -8
- package/src/index.ts +6 -1
- package/src/manifest-writer.ts +36 -7
- package/src/source-finder.ts +391 -95
- package/src/types.ts +27 -2
- package/src/vite-plugin.ts +5 -4
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.
|
|
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.
|
|
23
|
+
"astro": "^5.16.6",
|
|
24
24
|
"node-html-parser": "^6.1.13"
|
|
25
25
|
},
|
|
26
26
|
"devDependencies": {
|
package/src/astro-transform.ts
CHANGED
|
@@ -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:
|
|
17
|
-
*
|
|
18
|
-
*
|
|
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
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
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
|
package/src/build-processor.ts
CHANGED
|
@@ -4,7 +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
|
|
7
|
+
import { findCollectionSource, findMarkdownSourceLocation, findSourceLocation, parseMarkdownContent } from './source-finder'
|
|
8
|
+
import type { CmsMarkerOptions, CollectionEntry } from './types'
|
|
8
9
|
|
|
9
10
|
// Concurrency limit for parallel processing
|
|
10
11
|
const MAX_CONCURRENT = 10
|
|
@@ -47,6 +48,24 @@ async function processFile(
|
|
|
47
48
|
const pagePath = getPagePath(filePath, outDir)
|
|
48
49
|
const html = await fs.readFile(filePath, 'utf-8')
|
|
49
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
|
+
|
|
50
69
|
// Create ID generator - use atomic increment
|
|
51
70
|
const pageIdStart = idCounter.value
|
|
52
71
|
const idGenerator = () => `cms-${idCounter.value++}`
|
|
@@ -62,15 +81,69 @@ async function processFile(
|
|
|
62
81
|
generateManifest: config.generateManifest,
|
|
63
82
|
markComponents: config.markComponents,
|
|
64
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,
|
|
65
91
|
},
|
|
66
92
|
idGenerator,
|
|
67
93
|
)
|
|
68
94
|
|
|
69
|
-
//
|
|
70
|
-
//
|
|
95
|
+
// During build, source location attributes are not injected by astro-transform.ts
|
|
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
|
+
|
|
113
|
+
for (const entry of Object.values(result.entries)) {
|
|
114
|
+
// Skip entries that already have source info from component detection
|
|
115
|
+
if (entry.sourcePath && !entry.sourcePath.endsWith('.html')) {
|
|
116
|
+
continue
|
|
117
|
+
}
|
|
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
|
|
135
|
+
const sourceLocation = await findSourceLocation(entry.text, entry.tag)
|
|
136
|
+
if (sourceLocation) {
|
|
137
|
+
entry.sourcePath = sourceLocation.file
|
|
138
|
+
entry.sourceLine = sourceLocation.line
|
|
139
|
+
entry.sourceSnippet = sourceLocation.snippet
|
|
140
|
+
entry.sourceType = sourceLocation.type
|
|
141
|
+
entry.variableName = sourceLocation.variableName
|
|
142
|
+
}
|
|
143
|
+
}
|
|
71
144
|
|
|
72
145
|
// Add to manifest writer (handles per-page manifest writes)
|
|
73
|
-
manifestWriter.addPage(pagePath, result.entries, result.components)
|
|
146
|
+
manifestWriter.addPage(pagePath, result.entries, result.components, collectionEntry)
|
|
74
147
|
|
|
75
148
|
// Write transformed HTML back
|
|
76
149
|
await fs.writeFile(filePath, result.html, 'utf-8')
|
|
@@ -94,7 +94,7 @@ export class ComponentRegistry {
|
|
|
94
94
|
|
|
95
95
|
while (i < content.length) {
|
|
96
96
|
// Skip whitespace and newlines
|
|
97
|
-
while (i < content.length && /\s/.test(content[i])) i++
|
|
97
|
+
while (i < content.length && /\s/.test(content[i] ?? '')) i++
|
|
98
98
|
if (i >= content.length) break
|
|
99
99
|
|
|
100
100
|
// Skip comments
|
|
@@ -113,27 +113,27 @@ export class ComponentRegistry {
|
|
|
113
113
|
|
|
114
114
|
// Extract property name
|
|
115
115
|
const nameStart = i
|
|
116
|
-
while (i < content.length && /\w/.test(content[i])) i++
|
|
116
|
+
while (i < content.length && /\w/.test(content[i] ?? '')) i++
|
|
117
117
|
const name = content.substring(nameStart, i)
|
|
118
118
|
|
|
119
119
|
if (!name) break
|
|
120
120
|
|
|
121
121
|
// Skip whitespace
|
|
122
|
-
while (i < content.length && /\s/.test(content[i])) i++
|
|
122
|
+
while (i < content.length && /\s/.test(content[i] ?? '')) i++
|
|
123
123
|
|
|
124
124
|
// Check for optional marker
|
|
125
125
|
const optional = content[i] === '?'
|
|
126
126
|
if (optional) i++
|
|
127
127
|
|
|
128
128
|
// Skip whitespace
|
|
129
|
-
while (i < content.length && /\s/.test(content[i])) i++
|
|
129
|
+
while (i < content.length && /\s/.test(content[i] ?? '')) i++
|
|
130
130
|
|
|
131
131
|
// Expect colon
|
|
132
132
|
if (content[i] !== ':') break
|
|
133
133
|
i++
|
|
134
134
|
|
|
135
135
|
// Skip whitespace
|
|
136
|
-
while (i < content.length && /\s/.test(content[i])) i++
|
|
136
|
+
while (i < content.length && /\s/.test(content[i] ?? '')) i++
|
|
137
137
|
|
|
138
138
|
// Extract type (up to semicolon, handling nested braces)
|
|
139
139
|
const typeStart = i
|
|
@@ -154,7 +154,7 @@ export class ComponentRegistry {
|
|
|
154
154
|
if (content[i] === ';') i++
|
|
155
155
|
|
|
156
156
|
// Skip whitespace
|
|
157
|
-
while (i < content.length && /[ \t]/.test(content[i])) i++
|
|
157
|
+
while (i < content.length && /[ \t]/.test(content[i] ?? '')) i++
|
|
158
158
|
|
|
159
159
|
// Check for inline comment
|
|
160
160
|
let description: string | undefined
|
|
@@ -215,7 +215,7 @@ export class ComponentRegistry {
|
|
|
215
215
|
|
|
216
216
|
// Find the frontmatter section
|
|
217
217
|
const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---/)
|
|
218
|
-
if (!frontmatterMatch) return props
|
|
218
|
+
if (!frontmatterMatch?.[1]) return props
|
|
219
219
|
|
|
220
220
|
const frontmatter = frontmatterMatch[1]
|
|
221
221
|
|
package/src/dev-middleware.ts
CHANGED
|
@@ -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
|
|
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
|
}
|
package/src/html-processor.ts
CHANGED
|
@@ -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
|
-
|
|
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-
|
|
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
|
-
|
|
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
|
|
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
|
+
|
package/src/manifest-writer.ts
CHANGED
|
@@ -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, {
|
|
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
|
|
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): {
|
|
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
|
}
|
package/src/source-finder.ts
CHANGED
|
@@ -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
|
|
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 =
|
|
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:
|
|
222
|
-
textContent
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
const
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
//
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
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
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
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
|
-
|
|
295
|
+
if (!propValue) {
|
|
296
|
+
continue
|
|
297
|
+
}
|
|
280
298
|
|
|
281
|
-
|
|
282
|
-
// Find which line actually contains this prop
|
|
283
|
-
let propLine = i
|
|
284
|
-
let propLineIndex = i
|
|
299
|
+
const normalizedValue = normalizeText(propValue)
|
|
285
300
|
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
continue
|
|
290
|
-
}
|
|
301
|
+
if (normalizedValue === cleanText) {
|
|
302
|
+
// Find which line actually contains this prop
|
|
303
|
+
let propLine = i
|
|
291
304
|
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
}
|
|
305
|
+
for (let k = i; k <= endLine; k++) {
|
|
306
|
+
const line = lines[k]
|
|
307
|
+
if (!line) {
|
|
308
|
+
continue
|
|
297
309
|
}
|
|
298
310
|
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
continue
|
|
305
|
-
}
|
|
311
|
+
if (propName && line.includes(propName) && line.includes(propValue)) {
|
|
312
|
+
propLine = k
|
|
313
|
+
break
|
|
314
|
+
}
|
|
315
|
+
}
|
|
306
316
|
|
|
307
|
-
|
|
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
|
-
|
|
325
|
+
componentSnippetLines.push(line)
|
|
326
|
+
}
|
|
311
327
|
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
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
|
-
*
|
|
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
|
|
474
|
-
|
|
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
|
}
|
package/src/vite-plugin.ts
CHANGED
|
@@ -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
|
-
//
|
|
39
|
-
//
|
|
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:
|
|
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
|