@nuasite/cms-marker 0.0.42

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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
+ }