@nuasite/cms-marker 0.0.98 → 0.0.101

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,221 @@
1
+ import { parse } from 'node-html-parser'
2
+ import fs from 'node:fs/promises'
3
+ import path from 'node:path'
4
+ import type { ComponentDefinition, ComponentInstance, ManifestEntry } from './types'
5
+ import type { CollectionEntry, PageSeoData } from './types'
6
+
7
+ type PageData = {
8
+ entries: Record<string, ManifestEntry>
9
+ components: Record<string, ComponentInstance>
10
+ collection?: CollectionEntry
11
+ seo?: PageSeoData
12
+ }
13
+
14
+ /**
15
+ * Annotate elements in the component HTML with `data-cms-preview-prop` attributes.
16
+ * For each string prop, find the first leaf element whose trimmed text content
17
+ * matches the prop's rendered value, and tag it.
18
+ */
19
+ function annotatePreviewProps(
20
+ componentHtml: ReturnType<typeof parse>,
21
+ props: Record<string, any>,
22
+ propDefs: ComponentDefinition['props'],
23
+ ): void {
24
+ const annotated = new Set<string>()
25
+
26
+ for (const def of propDefs) {
27
+ // Only annotate string-type props
28
+ if (def.type !== 'string') continue
29
+ const value = props[def.name]
30
+ if (typeof value !== 'string' || !value.trim()) continue
31
+
32
+ const trimmedValue = value.trim()
33
+
34
+ // Find leaf text nodes whose content matches
35
+ const allElements = componentHtml.querySelectorAll('*')
36
+ for (const el of allElements) {
37
+ // Skip elements that already have an annotation
38
+ if (el.getAttribute('data-cms-preview-prop')) continue
39
+
40
+ // Check if this is a leaf element (no child elements, only text)
41
+ if (el.childNodes.length === 0) continue
42
+ const hasChildElements = el.childNodes.some(
43
+ (n) => n.nodeType === 1, // ELEMENT_NODE
44
+ )
45
+
46
+ // For leaf elements or elements with only text children
47
+ if (!hasChildElements) {
48
+ const textContent = el.textContent.trim()
49
+ if (textContent === trimmedValue && !annotated.has(def.name)) {
50
+ el.setAttribute('data-cms-preview-prop', def.name)
51
+ annotated.add(def.name)
52
+ break
53
+ }
54
+ }
55
+ }
56
+ }
57
+ }
58
+
59
+ /**
60
+ * Generate a standalone preview HTML page for a component.
61
+ */
62
+ function generatePreviewHtml(
63
+ componentOuterHtml: string,
64
+ headStyles: string,
65
+ ): string {
66
+ return `<!DOCTYPE html>
67
+ <html>
68
+ <head>
69
+ <meta charset="utf-8">
70
+ <meta name="robots" content="noindex, nofollow">
71
+ <meta name="viewport" content="width=device-width, initial-scale=1">
72
+ ${headStyles}
73
+ <style>
74
+ body { margin: 0; padding: 0; }
75
+ .cms-preview-container { overflow: hidden; }
76
+ </style>
77
+ </head>
78
+ <body>
79
+ <div class="cms-preview-container">${componentOuterHtml}</div>
80
+ <script>
81
+ // Notify parent that preview is ready
82
+ if (window.parent !== window) {
83
+ window.parent.postMessage({ type: 'cms-preview-ready' }, '*');
84
+ }
85
+
86
+ // Listen for prop updates from the CMS editor
87
+ window.addEventListener('message', function(event) {
88
+ if (!event.data || event.data.type !== 'cms-preview-update') return;
89
+ var props = event.data.props;
90
+ if (!props) return;
91
+
92
+ var elements = document.querySelectorAll('[data-cms-preview-prop]');
93
+ for (var i = 0; i < elements.length; i++) {
94
+ var el = elements[i];
95
+ var propName = el.getAttribute('data-cms-preview-prop');
96
+ if (propName && props[propName] !== undefined) {
97
+ el.textContent = String(props[propName]);
98
+ }
99
+ }
100
+ });
101
+ </script>
102
+ </body>
103
+ </html>`
104
+ }
105
+
106
+ /**
107
+ * Extract <link rel="stylesheet"> and <style> tags from a page's <head>.
108
+ */
109
+ function extractHeadStyles(root: ReturnType<typeof parse>): string {
110
+ const head = root.querySelector('head')
111
+ if (!head) return ''
112
+
113
+ const parts: string[] = []
114
+
115
+ // Extract <link rel="stylesheet"> tags
116
+ for (const link of head.querySelectorAll('link[rel="stylesheet"]')) {
117
+ parts.push(link.outerHTML)
118
+ }
119
+
120
+ // Extract <style> tags
121
+ for (const style of head.querySelectorAll('style')) {
122
+ parts.push(style.outerHTML)
123
+ }
124
+
125
+ return parts.join('\n')
126
+ }
127
+
128
+ /**
129
+ * Generate standalone preview HTML files for each component that has
130
+ * at least one instance on a built page.
131
+ *
132
+ * Reads the built HTML, extracts the component DOM fragment, annotates
133
+ * text props for live preview updates, and writes a self-contained HTML
134
+ * page to `outDir/_cms-preview/<ComponentName>/index.html`.
135
+ */
136
+ export async function generateComponentPreviews(
137
+ outDir: string,
138
+ pageManifests: Map<string, PageData>,
139
+ componentDefinitions: Record<string, ComponentDefinition>,
140
+ ): Promise<void> {
141
+ // Track which component names we've already processed
142
+ const processed = new Set<string>()
143
+
144
+ // Build a list of work: for each page, find components we haven't processed yet
145
+ for (const [pagePath, pageData] of pageManifests) {
146
+ const componentsToProcess: Array<{
147
+ componentName: string
148
+ instance: ComponentInstance
149
+ }> = []
150
+
151
+ for (const instance of Object.values(pageData.components)) {
152
+ if (processed.has(instance.componentName)) continue
153
+ if (!componentDefinitions[instance.componentName]) continue
154
+ processed.add(instance.componentName)
155
+ componentsToProcess.push({ componentName: instance.componentName, instance })
156
+ }
157
+
158
+ if (componentsToProcess.length === 0) continue
159
+
160
+ // Resolve the HTML file path for this page
161
+ let htmlFilePath: string
162
+ if (pagePath === '/' || pagePath === '') {
163
+ htmlFilePath = path.join(outDir, 'index.html')
164
+ } else {
165
+ const cleanPath = pagePath.replace(/^\//, '')
166
+ // Try directory-style first (e.g., about/index.html)
167
+ const dirStyle = path.join(outDir, cleanPath, 'index.html')
168
+ const fileStyle = path.join(outDir, `${cleanPath}.html`)
169
+ try {
170
+ await fs.access(dirStyle)
171
+ htmlFilePath = dirStyle
172
+ } catch {
173
+ htmlFilePath = fileStyle
174
+ }
175
+ }
176
+
177
+ let pageHtml: string
178
+ try {
179
+ pageHtml = await fs.readFile(htmlFilePath, 'utf-8')
180
+ } catch {
181
+ // Page HTML not found, skip
182
+ continue
183
+ }
184
+
185
+ const root = parse(pageHtml, { lowerCaseTagName: false, comment: true })
186
+ const headStyles = extractHeadStyles(root)
187
+
188
+ for (const { componentName, instance } of componentsToProcess) {
189
+ const def = componentDefinitions[componentName]
190
+ if (!def) continue
191
+
192
+ // Find the component element in the DOM
193
+ const componentEl = root.querySelector(
194
+ `[data-cms-component-id="${instance.id}"]`,
195
+ )
196
+ if (!componentEl) continue
197
+
198
+ // Clone the component HTML for annotation
199
+ const componentFragment = parse(componentEl.outerHTML, {
200
+ lowerCaseTagName: false,
201
+ comment: true,
202
+ })
203
+
204
+ // Annotate text props for live preview
205
+ annotatePreviewProps(componentFragment, instance.props, def.props)
206
+
207
+ const previewHtml = generatePreviewHtml(
208
+ componentFragment.toString(),
209
+ headStyles,
210
+ )
211
+
212
+ // Write preview file
213
+ const previewDir = path.join(outDir, '_cms-preview', componentName)
214
+ await fs.mkdir(previewDir, { recursive: true })
215
+ await fs.writeFile(path.join(previewDir, 'index.html'), previewHtml, 'utf-8')
216
+
217
+ // Set the preview URL on the component definition
218
+ def.previewUrl = `/_cms-preview/${componentName}/`
219
+ }
220
+ }
221
+ }
package/src/types.ts CHANGED
@@ -37,6 +37,7 @@ export interface ComponentDefinition {
37
37
  props: ComponentProp[]
38
38
  description?: string
39
39
  slots?: string[]
40
+ previewUrl?: string
40
41
  }
41
42
 
42
43
  /** Image metadata for better tracking and integrity */