@nuasite/cms-marker 0.0.42
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +240 -0
- package/package.json +42 -0
- package/src/astro-transform.ts +193 -0
- package/src/build-processor.ts +164 -0
- package/src/component-registry.ts +382 -0
- package/src/dev-middleware.ts +183 -0
- package/src/html-processor.ts +359 -0
- package/src/index.ts +91 -0
- package/src/manifest-writer.ts +153 -0
- package/src/source-finder.ts +475 -0
- package/src/tsconfig.json +6 -0
- package/src/types.ts +57 -0
- package/src/vite-plugin.ts +45 -0
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
import { parse } from 'node-html-parser'
|
|
2
|
+
import type { ComponentInstance, ManifestEntry } from './types'
|
|
3
|
+
|
|
4
|
+
export interface ProcessHtmlOptions {
|
|
5
|
+
attributeName: string
|
|
6
|
+
includeTags: string[] | null
|
|
7
|
+
excludeTags: string[]
|
|
8
|
+
includeEmptyText: boolean
|
|
9
|
+
generateManifest: boolean
|
|
10
|
+
markComponents?: boolean
|
|
11
|
+
componentDirs?: string[]
|
|
12
|
+
excludeComponentDirs?: string[]
|
|
13
|
+
markStyledSpans?: boolean
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface ProcessHtmlResult {
|
|
17
|
+
html: string
|
|
18
|
+
entries: Record<string, ManifestEntry>
|
|
19
|
+
components: Record<string, ComponentInstance>
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Tailwind text styling class patterns that indicate a styled span.
|
|
24
|
+
* These are classes that only affect text appearance, not layout.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
// Known layout-affecting classes that should NOT be considered text styling
|
|
28
|
+
const LAYOUT_CLASS_PATTERNS = [
|
|
29
|
+
// Text alignment
|
|
30
|
+
/^text-(left|center|right|justify|start|end)$/,
|
|
31
|
+
// Text wrapping and overflow
|
|
32
|
+
/^text-(wrap|nowrap|balance|pretty|ellipsis|clip)$/,
|
|
33
|
+
// Vertical alignment
|
|
34
|
+
/^align-/,
|
|
35
|
+
// Background attachment, size, repeat, position
|
|
36
|
+
/^bg-(fixed|local|scroll)$/,
|
|
37
|
+
/^bg-(auto|cover|contain)$/,
|
|
38
|
+
/^bg-(repeat|no-repeat|repeat-x|repeat-y|repeat-round|repeat-space)$/,
|
|
39
|
+
/^bg-clip-/,
|
|
40
|
+
/^bg-origin-/,
|
|
41
|
+
/^bg-(top|bottom|left|right|center)$/,
|
|
42
|
+
/^bg-(top|bottom)-(left|right)$/,
|
|
43
|
+
]
|
|
44
|
+
|
|
45
|
+
const TEXT_STYLE_PATTERNS = [
|
|
46
|
+
// Font weight
|
|
47
|
+
/^font-(thin|extralight|light|normal|medium|semibold|bold|extrabold|black|\d+)$/,
|
|
48
|
+
// Font style
|
|
49
|
+
/^(italic|not-italic)$/,
|
|
50
|
+
// Text decoration
|
|
51
|
+
/^(underline|overline|line-through|no-underline)$/,
|
|
52
|
+
// Text decoration style
|
|
53
|
+
/^decoration-(solid|double|dotted|dashed|wavy)$/,
|
|
54
|
+
// Text decoration color (any color, including custom ones)
|
|
55
|
+
/^decoration-[\w-]+$/,
|
|
56
|
+
// Text decoration thickness
|
|
57
|
+
/^decoration-(auto|from-font|0|1|2|4|8)$/,
|
|
58
|
+
// Text underline offset
|
|
59
|
+
/^underline-offset-/,
|
|
60
|
+
// Text transform
|
|
61
|
+
/^(uppercase|lowercase|capitalize|normal-case)$/,
|
|
62
|
+
// Text color (any custom color - layout classes excluded separately)
|
|
63
|
+
/^text-[\w-]+$/,
|
|
64
|
+
// Background color (any custom color - layout classes excluded separately)
|
|
65
|
+
/^bg-[\w-]+$/,
|
|
66
|
+
// Font size
|
|
67
|
+
/^text-(xs|sm|base|lg|xl|2xl|3xl|4xl|5xl|6xl|7xl|8xl|9xl)$/,
|
|
68
|
+
// Letter spacing
|
|
69
|
+
/^tracking-/,
|
|
70
|
+
// Line height
|
|
71
|
+
/^leading-/,
|
|
72
|
+
]
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Check if a class is a text styling class
|
|
76
|
+
*/
|
|
77
|
+
function isTextStyleClass(className: string): boolean {
|
|
78
|
+
// First check if it's a known layout class
|
|
79
|
+
if (LAYOUT_CLASS_PATTERNS.some(pattern => pattern.test(className))) {
|
|
80
|
+
return false
|
|
81
|
+
}
|
|
82
|
+
// Then check if it matches any text style pattern
|
|
83
|
+
return TEXT_STYLE_PATTERNS.some(pattern => pattern.test(className))
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Check if all classes on an element are text styling classes
|
|
88
|
+
*/
|
|
89
|
+
function hasOnlyTextStyleClasses(classAttr: string): boolean {
|
|
90
|
+
if (!classAttr || !classAttr.trim()) return false
|
|
91
|
+
|
|
92
|
+
const classes = classAttr.split(/\s+/).filter(Boolean)
|
|
93
|
+
if (classes.length === 0) return false
|
|
94
|
+
|
|
95
|
+
// All classes must be text styling classes
|
|
96
|
+
return classes.every(isTextStyleClass)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Process HTML to inject CMS markers and extract manifest entries
|
|
101
|
+
*/
|
|
102
|
+
export async function processHtml(
|
|
103
|
+
html: string,
|
|
104
|
+
fileId: string,
|
|
105
|
+
options: ProcessHtmlOptions,
|
|
106
|
+
getNextId: () => string,
|
|
107
|
+
sourcePath?: string,
|
|
108
|
+
): Promise<ProcessHtmlResult> {
|
|
109
|
+
const {
|
|
110
|
+
attributeName,
|
|
111
|
+
includeTags,
|
|
112
|
+
excludeTags,
|
|
113
|
+
includeEmptyText,
|
|
114
|
+
generateManifest,
|
|
115
|
+
markComponents = true,
|
|
116
|
+
componentDirs = ['src/components'],
|
|
117
|
+
excludeComponentDirs = ['src/pages', 'src/layouts', 'src/layout'],
|
|
118
|
+
markStyledSpans = true,
|
|
119
|
+
} = options
|
|
120
|
+
|
|
121
|
+
const root = parse(html, {
|
|
122
|
+
lowerCaseTagName: false,
|
|
123
|
+
comment: true,
|
|
124
|
+
blockTextElements: {
|
|
125
|
+
script: true,
|
|
126
|
+
noscript: true,
|
|
127
|
+
style: true,
|
|
128
|
+
pre: true,
|
|
129
|
+
},
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
const entries: Record<string, ManifestEntry> = {}
|
|
133
|
+
const components: Record<string, ComponentInstance> = {}
|
|
134
|
+
const sourceLocationMap = new Map<string, { file: string; line: number }>()
|
|
135
|
+
const markedComponentRoots = new Set<any>()
|
|
136
|
+
|
|
137
|
+
// First pass: detect and mark component root elements
|
|
138
|
+
// A component root is detected by data-astro-source-file pointing to a component directory
|
|
139
|
+
if (markComponents) {
|
|
140
|
+
root.querySelectorAll('*').forEach((node) => {
|
|
141
|
+
const sourceFile = node.getAttribute('data-astro-source-file')
|
|
142
|
+
if (!sourceFile) return
|
|
143
|
+
|
|
144
|
+
// Check if this element's source is from a component file
|
|
145
|
+
// Exclude pages and layouts first
|
|
146
|
+
const isExcludedFile = excludeComponentDirs.some(dir => {
|
|
147
|
+
const normalizedDir = dir.replace(/^\/+|\/+$/g, '')
|
|
148
|
+
return sourceFile.startsWith(normalizedDir + '/')
|
|
149
|
+
|| sourceFile.startsWith(normalizedDir + '\\')
|
|
150
|
+
|| sourceFile.includes('/' + normalizedDir + '/')
|
|
151
|
+
|| sourceFile.includes('\\' + normalizedDir + '\\')
|
|
152
|
+
})
|
|
153
|
+
if (isExcludedFile) return
|
|
154
|
+
|
|
155
|
+
// If componentDirs is specified, also check whitelist
|
|
156
|
+
if (componentDirs.length > 0) {
|
|
157
|
+
const isComponentFile = componentDirs.some(dir => {
|
|
158
|
+
const normalizedDir = dir.replace(/^\/+|\/+$/g, '')
|
|
159
|
+
return sourceFile.startsWith(normalizedDir + '/')
|
|
160
|
+
|| sourceFile.startsWith(normalizedDir + '\\')
|
|
161
|
+
|| sourceFile.includes('/' + normalizedDir + '/')
|
|
162
|
+
|| sourceFile.includes('\\' + normalizedDir + '\\')
|
|
163
|
+
})
|
|
164
|
+
if (!isComponentFile) return
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Check if any ancestor is already marked as a component root from the same file
|
|
168
|
+
// (we only want to mark the outermost element from each component)
|
|
169
|
+
let parent = node.parentNode
|
|
170
|
+
let ancestorFromSameComponent = false
|
|
171
|
+
while (parent) {
|
|
172
|
+
const parentSource = (parent as any).getAttribute?.('data-astro-source-file')
|
|
173
|
+
if (parentSource === sourceFile) {
|
|
174
|
+
ancestorFromSameComponent = true
|
|
175
|
+
break
|
|
176
|
+
}
|
|
177
|
+
parent = parent.parentNode
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
if (ancestorFromSameComponent) return
|
|
181
|
+
|
|
182
|
+
// This is a component root - mark it
|
|
183
|
+
const id = getNextId()
|
|
184
|
+
node.setAttribute('data-cms-component-id', id)
|
|
185
|
+
markedComponentRoots.add(node)
|
|
186
|
+
|
|
187
|
+
// Extract component name from file path (e.g., "src/components/Welcome.astro" -> "Welcome")
|
|
188
|
+
const componentName = extractComponentName(sourceFile)
|
|
189
|
+
// Parse source loc - format is "line:col" e.g. "20:21"
|
|
190
|
+
const sourceLocAttr = node.getAttribute('data-astro-source-line') || '1:0'
|
|
191
|
+
const sourceLine = parseInt(sourceLocAttr.split(':')[0] ?? '1', 10)
|
|
192
|
+
|
|
193
|
+
components[id] = {
|
|
194
|
+
id,
|
|
195
|
+
componentName,
|
|
196
|
+
file: fileId,
|
|
197
|
+
sourcePath: sourceFile,
|
|
198
|
+
sourceLine,
|
|
199
|
+
props: {}, // Props will be filled from component definitions
|
|
200
|
+
}
|
|
201
|
+
})
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Second pass: mark span elements with text-only styling classes as styled spans
|
|
205
|
+
// This allows the CMS editor to recognize pre-existing styled text
|
|
206
|
+
if (markStyledSpans) {
|
|
207
|
+
root.querySelectorAll('span').forEach((node) => {
|
|
208
|
+
// Skip if already marked
|
|
209
|
+
if (node.getAttribute('data-cms-styled')) return
|
|
210
|
+
|
|
211
|
+
const classAttr = node.getAttribute('class')
|
|
212
|
+
if (!classAttr) return
|
|
213
|
+
|
|
214
|
+
// Check if the span has only text styling classes
|
|
215
|
+
if (hasOnlyTextStyleClasses(classAttr)) {
|
|
216
|
+
node.setAttribute('data-cms-styled', 'true')
|
|
217
|
+
}
|
|
218
|
+
})
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Third pass: assign IDs to all qualifying text elements and extract source locations
|
|
222
|
+
root.querySelectorAll('*').forEach((node) => {
|
|
223
|
+
const tag = node.tagName?.toLowerCase?.() ?? ''
|
|
224
|
+
|
|
225
|
+
if (excludeTags.includes(tag)) return
|
|
226
|
+
if (includeTags && !includeTags.includes(tag)) return
|
|
227
|
+
if (node.getAttribute(attributeName)) return // Already marked
|
|
228
|
+
|
|
229
|
+
const textContent = (node.innerText ?? '').trim()
|
|
230
|
+
if (!includeEmptyText && !textContent) return
|
|
231
|
+
|
|
232
|
+
const id = getNextId()
|
|
233
|
+
node.setAttribute(attributeName, id)
|
|
234
|
+
|
|
235
|
+
// Extract source location from Astro compiler attributes
|
|
236
|
+
const sourceFile = node.getAttribute('data-astro-source-file')
|
|
237
|
+
const sourceLine = node.getAttribute('data-astro-source-line')
|
|
238
|
+
|
|
239
|
+
if (sourceFile && sourceLine) {
|
|
240
|
+
const lineNum = parseInt(sourceLine.split(':')[0] ?? '1', 10)
|
|
241
|
+
if (!isNaN(lineNum)) {
|
|
242
|
+
sourceLocationMap.set(id, { file: sourceFile, line: lineNum })
|
|
243
|
+
}
|
|
244
|
+
// Only remove source attributes if this is NOT a component root
|
|
245
|
+
// Component roots need these for identification
|
|
246
|
+
if (!markedComponentRoots.has(node)) {
|
|
247
|
+
node.removeAttribute('data-astro-source-file')
|
|
248
|
+
node.removeAttribute('data-astro-source-line')
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
})
|
|
252
|
+
|
|
253
|
+
// Fourth pass: build manifest entries
|
|
254
|
+
if (generateManifest) {
|
|
255
|
+
root.querySelectorAll(`[${attributeName}]`).forEach((node) => {
|
|
256
|
+
const id = node.getAttribute(attributeName)
|
|
257
|
+
if (!id) return
|
|
258
|
+
|
|
259
|
+
const tag = node.tagName?.toLowerCase?.() ?? ''
|
|
260
|
+
|
|
261
|
+
// Get child CMS elements
|
|
262
|
+
const childCmsElements = node.querySelectorAll(`[${attributeName}]`)
|
|
263
|
+
const childCmsIds = Array.from(childCmsElements).map((child: any) => child.getAttribute(attributeName))
|
|
264
|
+
|
|
265
|
+
// Build text with placeholders for child CMS elements
|
|
266
|
+
// Recursively process child nodes to handle nested CMS elements correctly
|
|
267
|
+
const buildTextWithPlaceholders = (nodes: any[]): string => {
|
|
268
|
+
let text = ''
|
|
269
|
+
for (const child of nodes) {
|
|
270
|
+
if (child.nodeType === 3) {
|
|
271
|
+
// Text node
|
|
272
|
+
text += child.text || ''
|
|
273
|
+
} else if (child.nodeType === 1) {
|
|
274
|
+
// Element node
|
|
275
|
+
const directCmsId = (child as any).getAttribute?.(attributeName)
|
|
276
|
+
|
|
277
|
+
if (directCmsId) {
|
|
278
|
+
// Child has a direct CMS ID - use placeholder
|
|
279
|
+
text += `{{cms:${directCmsId}}}`
|
|
280
|
+
} else {
|
|
281
|
+
// Child doesn't have a CMS ID - recursively process its children
|
|
282
|
+
text += buildTextWithPlaceholders(child.childNodes || [])
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
return text
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
const textWithPlaceholders = buildTextWithPlaceholders(node.childNodes || [])
|
|
290
|
+
|
|
291
|
+
// Get direct text content (without placeholders)
|
|
292
|
+
const directText = textWithPlaceholders.replace(/\{\{cms:[^}]+\}\}/g, '').trim()
|
|
293
|
+
|
|
294
|
+
// Skip pure container elements (no direct text, only child CMS elements)
|
|
295
|
+
if (!directText && childCmsIds.length > 0) {
|
|
296
|
+
return
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// Get source location from map (injected by Astro compiler)
|
|
300
|
+
const sourceLocation = sourceLocationMap.get(id)
|
|
301
|
+
|
|
302
|
+
// Find parent component if any
|
|
303
|
+
let parentComponentId: string | undefined
|
|
304
|
+
let parent = node.parentNode
|
|
305
|
+
while (parent) {
|
|
306
|
+
const parentCompId = (parent as any).getAttribute?.('data-cms-component-id')
|
|
307
|
+
if (parentCompId) {
|
|
308
|
+
parentComponentId = parentCompId
|
|
309
|
+
break
|
|
310
|
+
}
|
|
311
|
+
parent = parent.parentNode
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
entries[id] = {
|
|
315
|
+
id,
|
|
316
|
+
file: fileId,
|
|
317
|
+
tag,
|
|
318
|
+
text: textWithPlaceholders.trim(),
|
|
319
|
+
sourcePath: sourceLocation?.file || sourcePath,
|
|
320
|
+
childCmsIds: childCmsIds.length > 0 ? childCmsIds : undefined,
|
|
321
|
+
sourceLine: sourceLocation?.line,
|
|
322
|
+
sourceSnippet: undefined,
|
|
323
|
+
sourceType: undefined,
|
|
324
|
+
variableName: undefined,
|
|
325
|
+
parentComponentId,
|
|
326
|
+
}
|
|
327
|
+
})
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// Clean up any remaining source attributes from component-marked elements
|
|
331
|
+
markedComponentRoots.forEach((node: any) => {
|
|
332
|
+
node.removeAttribute('data-astro-source-file')
|
|
333
|
+
node.removeAttribute('data-astro-source-line')
|
|
334
|
+
})
|
|
335
|
+
|
|
336
|
+
return {
|
|
337
|
+
html: root.toString(),
|
|
338
|
+
entries,
|
|
339
|
+
components,
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* Extract component name from source file path
|
|
345
|
+
* e.g., "src/components/Welcome.astro" -> "Welcome"
|
|
346
|
+
* e.g., "src/components/ui/Button.astro" -> "Button"
|
|
347
|
+
*/
|
|
348
|
+
function extractComponentName(sourceFile: string): string {
|
|
349
|
+
const parts = sourceFile.split('/')
|
|
350
|
+
const fileName = parts[parts.length - 1] || ''
|
|
351
|
+
return fileName.replace('.astro', '')
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* Clean text for comparison (normalize whitespace)
|
|
356
|
+
*/
|
|
357
|
+
export function cleanText(text: string): string {
|
|
358
|
+
return text.trim().replace(/\s+/g, ' ').toLowerCase()
|
|
359
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import type { AstroIntegration } from 'astro'
|
|
2
|
+
import { processBuildOutput } from './build-processor'
|
|
3
|
+
import { ComponentRegistry } from './component-registry'
|
|
4
|
+
import { createDevMiddleware } from './dev-middleware'
|
|
5
|
+
import { ManifestWriter } from './manifest-writer'
|
|
6
|
+
import type { CmsMarkerOptions, ComponentDefinition } from './types'
|
|
7
|
+
import { createVitePlugin } from './vite-plugin'
|
|
8
|
+
|
|
9
|
+
export default function cmsMarker(options: CmsMarkerOptions = {}): AstroIntegration {
|
|
10
|
+
const {
|
|
11
|
+
attributeName = 'data-cms-id',
|
|
12
|
+
includeTags = null,
|
|
13
|
+
excludeTags = ['html', 'head', 'body', 'script', 'style'],
|
|
14
|
+
includeEmptyText = false,
|
|
15
|
+
generateManifest = true,
|
|
16
|
+
manifestFile = 'cms-manifest.json',
|
|
17
|
+
markComponents = true,
|
|
18
|
+
componentDirs = ['src/components'],
|
|
19
|
+
} = options
|
|
20
|
+
|
|
21
|
+
let componentDefinitions: Record<string, ComponentDefinition> = {}
|
|
22
|
+
|
|
23
|
+
// Shared counter for generating unique IDs across all pages
|
|
24
|
+
const idCounter = { value: 0 }
|
|
25
|
+
|
|
26
|
+
// Create manifest writer instance that persists across the build
|
|
27
|
+
const manifestWriter = new ManifestWriter(manifestFile, componentDefinitions)
|
|
28
|
+
|
|
29
|
+
const config = {
|
|
30
|
+
attributeName,
|
|
31
|
+
includeTags,
|
|
32
|
+
excludeTags,
|
|
33
|
+
includeEmptyText,
|
|
34
|
+
generateManifest,
|
|
35
|
+
manifestFile,
|
|
36
|
+
markComponents,
|
|
37
|
+
componentDirs,
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return {
|
|
41
|
+
name: 'astro-cms-marker',
|
|
42
|
+
hooks: {
|
|
43
|
+
'astro:config:setup': async ({ updateConfig, command, logger }) => {
|
|
44
|
+
// Reset state for new build/dev session
|
|
45
|
+
idCounter.value = 0
|
|
46
|
+
manifestWriter.reset()
|
|
47
|
+
|
|
48
|
+
// Scan for component definitions
|
|
49
|
+
if (markComponents) {
|
|
50
|
+
const registry = new ComponentRegistry(componentDirs)
|
|
51
|
+
await registry.scan()
|
|
52
|
+
componentDefinitions = registry.getComponents()
|
|
53
|
+
manifestWriter.setComponentDefinitions(componentDefinitions)
|
|
54
|
+
|
|
55
|
+
const componentCount = Object.keys(componentDefinitions).length
|
|
56
|
+
if (componentCount > 0) {
|
|
57
|
+
logger.info(`Found ${componentCount} component definitions`)
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Create Vite plugin context
|
|
62
|
+
const pluginContext = {
|
|
63
|
+
manifestWriter,
|
|
64
|
+
componentDefinitions,
|
|
65
|
+
config,
|
|
66
|
+
idCounter,
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
updateConfig({
|
|
70
|
+
vite: {
|
|
71
|
+
plugins: [createVitePlugin(pluginContext)],
|
|
72
|
+
},
|
|
73
|
+
})
|
|
74
|
+
},
|
|
75
|
+
|
|
76
|
+
'astro:server:setup': ({ server, logger }) => {
|
|
77
|
+
createDevMiddleware(server, config, manifestWriter, componentDefinitions, idCounter)
|
|
78
|
+
logger.info('Dev middleware initialized')
|
|
79
|
+
},
|
|
80
|
+
|
|
81
|
+
'astro:build:done': async ({ dir, logger }) => {
|
|
82
|
+
if (generateManifest) {
|
|
83
|
+
await processBuildOutput(dir, config, manifestWriter, idCounter, logger)
|
|
84
|
+
}
|
|
85
|
+
},
|
|
86
|
+
},
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Re-export types for consumers
|
|
91
|
+
export type { CmsManifest, CmsMarkerOptions, ComponentDefinition, ComponentInstance, ManifestEntry } from './types'
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import fs from 'node:fs/promises'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import type { CmsManifest, ComponentDefinition, ComponentInstance, ManifestEntry } from './types'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Manages streaming manifest writes during build.
|
|
7
|
+
* Accumulates entries and writes per-page manifests as pages are processed.
|
|
8
|
+
*/
|
|
9
|
+
export class ManifestWriter {
|
|
10
|
+
private globalManifest: CmsManifest
|
|
11
|
+
private pageManifests: Map<string, { entries: Record<string, ManifestEntry>; components: Record<string, ComponentInstance> }> = new Map()
|
|
12
|
+
private outDir: string = ''
|
|
13
|
+
private manifestFile: string
|
|
14
|
+
private componentDefinitions: Record<string, ComponentDefinition>
|
|
15
|
+
private writeQueue: Promise<void> = Promise.resolve()
|
|
16
|
+
|
|
17
|
+
constructor(manifestFile: string = 'cms-manifest.json', componentDefinitions: Record<string, ComponentDefinition> = {}) {
|
|
18
|
+
this.manifestFile = manifestFile
|
|
19
|
+
this.componentDefinitions = componentDefinitions
|
|
20
|
+
this.globalManifest = {
|
|
21
|
+
entries: {},
|
|
22
|
+
components: {},
|
|
23
|
+
componentDefinitions,
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Set the output directory for manifest files
|
|
29
|
+
*/
|
|
30
|
+
setOutDir(dir: string): void {
|
|
31
|
+
this.outDir = dir
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Update component definitions (called after initial scan)
|
|
36
|
+
*/
|
|
37
|
+
setComponentDefinitions(definitions: Record<string, ComponentDefinition>): void {
|
|
38
|
+
this.componentDefinitions = definitions
|
|
39
|
+
this.globalManifest.componentDefinitions = definitions
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Get the manifest path for a given page
|
|
44
|
+
* Places manifest next to the page: /about -> /about.json, / -> /index.json
|
|
45
|
+
*/
|
|
46
|
+
private getPageManifestPath(pagePath: string): string {
|
|
47
|
+
if (pagePath === '/' || pagePath === '') {
|
|
48
|
+
return path.join(this.outDir, 'index.json')
|
|
49
|
+
}
|
|
50
|
+
const cleanPath = pagePath.replace(/^\//, '')
|
|
51
|
+
return path.join(this.outDir, `${cleanPath}.json`)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Add a page's entries to the manifest (called after each page is processed)
|
|
56
|
+
* This is non-blocking - writes are queued
|
|
57
|
+
*/
|
|
58
|
+
addPage(
|
|
59
|
+
pagePath: string,
|
|
60
|
+
entries: Record<string, ManifestEntry>,
|
|
61
|
+
components: Record<string, ComponentInstance>,
|
|
62
|
+
): void {
|
|
63
|
+
// Store in memory
|
|
64
|
+
this.pageManifests.set(pagePath, { entries, components })
|
|
65
|
+
|
|
66
|
+
// Update global manifest
|
|
67
|
+
Object.assign(this.globalManifest.entries, entries)
|
|
68
|
+
Object.assign(this.globalManifest.components, components)
|
|
69
|
+
|
|
70
|
+
// Queue the write operation (non-blocking)
|
|
71
|
+
if (this.outDir) {
|
|
72
|
+
this.writeQueue = this.writeQueue.then(() => this.writePageManifest(pagePath, entries, components))
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Write a single page manifest to disk
|
|
78
|
+
*/
|
|
79
|
+
private async writePageManifest(
|
|
80
|
+
pagePath: string,
|
|
81
|
+
entries: Record<string, ManifestEntry>,
|
|
82
|
+
components: Record<string, ComponentInstance>,
|
|
83
|
+
): Promise<void> {
|
|
84
|
+
const manifestPath = this.getPageManifestPath(pagePath)
|
|
85
|
+
const manifestDir = path.dirname(manifestPath)
|
|
86
|
+
|
|
87
|
+
await fs.mkdir(manifestDir, { recursive: true })
|
|
88
|
+
|
|
89
|
+
const pageManifest = {
|
|
90
|
+
page: pagePath,
|
|
91
|
+
entries,
|
|
92
|
+
components,
|
|
93
|
+
componentDefinitions: this.componentDefinitions,
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
await fs.writeFile(manifestPath, JSON.stringify(pageManifest, null, 2), 'utf-8')
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Finalize manifest writes
|
|
101
|
+
* Call this in astro:build:done to ensure all writes complete
|
|
102
|
+
*/
|
|
103
|
+
async finalize(): Promise<{ totalEntries: number; totalPages: number; totalComponents: number }> {
|
|
104
|
+
// Wait for all queued writes to complete
|
|
105
|
+
await this.writeQueue
|
|
106
|
+
|
|
107
|
+
// Write global manifest with settings (component definitions only, not entries)
|
|
108
|
+
if (this.outDir) {
|
|
109
|
+
const globalManifestPath = path.join(this.outDir, this.manifestFile)
|
|
110
|
+
const globalSettings = {
|
|
111
|
+
componentDefinitions: this.componentDefinitions,
|
|
112
|
+
}
|
|
113
|
+
await fs.writeFile(
|
|
114
|
+
globalManifestPath,
|
|
115
|
+
JSON.stringify(globalSettings, null, 2),
|
|
116
|
+
'utf-8',
|
|
117
|
+
)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return {
|
|
121
|
+
totalEntries: Object.keys(this.globalManifest.entries).length,
|
|
122
|
+
totalPages: this.pageManifests.size,
|
|
123
|
+
totalComponents: Object.keys(this.globalManifest.components).length,
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Get the global manifest (for virtual module support)
|
|
129
|
+
*/
|
|
130
|
+
getGlobalManifest(): CmsManifest {
|
|
131
|
+
return this.globalManifest
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Get a page's manifest data (for dev mode)
|
|
136
|
+
*/
|
|
137
|
+
getPageManifest(pagePath: string): { entries: Record<string, ManifestEntry>; components: Record<string, ComponentInstance> } | undefined {
|
|
138
|
+
return this.pageManifests.get(pagePath)
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Reset state (for dev mode reloads)
|
|
143
|
+
*/
|
|
144
|
+
reset(): void {
|
|
145
|
+
this.pageManifests.clear()
|
|
146
|
+
this.globalManifest = {
|
|
147
|
+
entries: {},
|
|
148
|
+
components: {},
|
|
149
|
+
componentDefinitions: this.componentDefinitions,
|
|
150
|
+
}
|
|
151
|
+
this.writeQueue = Promise.resolve()
|
|
152
|
+
}
|
|
153
|
+
}
|