@nuasite/cms 0.1.2 → 0.3.0

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.
Files changed (55) hide show
  1. package/README.md +96 -84
  2. package/dist/src/build-processor.d.ts.map +1 -1
  3. package/dist/src/component-registry.d.ts +6 -2
  4. package/dist/src/component-registry.d.ts.map +1 -1
  5. package/dist/src/dev-middleware.d.ts.map +1 -1
  6. package/dist/src/editor/api.d.ts +14 -0
  7. package/dist/src/editor/api.d.ts.map +1 -1
  8. package/dist/src/editor/components/ai-chat.d.ts.map +1 -1
  9. package/dist/src/editor/components/block-editor.d.ts.map +1 -1
  10. package/dist/src/editor/components/color-toolbar.d.ts.map +1 -1
  11. package/dist/src/editor/components/editable-highlights.d.ts.map +1 -1
  12. package/dist/src/editor/components/outline.d.ts.map +1 -1
  13. package/dist/src/editor/constants.d.ts +1 -0
  14. package/dist/src/editor/constants.d.ts.map +1 -1
  15. package/dist/src/editor/dom.d.ts +9 -0
  16. package/dist/src/editor/dom.d.ts.map +1 -1
  17. package/dist/src/editor/editor.d.ts.map +1 -1
  18. package/dist/src/editor/history.d.ts.map +1 -1
  19. package/dist/src/editor/hooks/useBlockEditorHandlers.d.ts.map +1 -1
  20. package/dist/src/editor/index.d.ts.map +1 -1
  21. package/dist/src/editor/storage.d.ts +2 -0
  22. package/dist/src/editor/storage.d.ts.map +1 -1
  23. package/dist/src/handlers/array-ops.d.ts +59 -0
  24. package/dist/src/handlers/array-ops.d.ts.map +1 -0
  25. package/dist/src/handlers/component-ops.d.ts +26 -0
  26. package/dist/src/handlers/component-ops.d.ts.map +1 -1
  27. package/dist/src/index.d.ts.map +1 -1
  28. package/dist/src/source-finder/cross-file-tracker.d.ts.map +1 -1
  29. package/dist/src/tsconfig.tsbuildinfo +1 -1
  30. package/package.json +1 -1
  31. package/src/build-processor.ts +27 -0
  32. package/src/component-registry.ts +125 -76
  33. package/src/dev-middleware.ts +85 -16
  34. package/src/editor/api.ts +72 -0
  35. package/src/editor/components/ai-chat.tsx +0 -1
  36. package/src/editor/components/block-editor.tsx +92 -17
  37. package/src/editor/components/color-toolbar.tsx +7 -1
  38. package/src/editor/components/editable-highlights.tsx +4 -1
  39. package/src/editor/components/outline.tsx +11 -6
  40. package/src/editor/constants.ts +1 -0
  41. package/src/editor/dom.ts +46 -1
  42. package/src/editor/editor.ts +5 -2
  43. package/src/editor/history.ts +1 -6
  44. package/src/editor/hooks/useBlockEditorHandlers.ts +86 -29
  45. package/src/editor/index.tsx +24 -8
  46. package/src/editor/storage.ts +24 -0
  47. package/src/handlers/array-ops.ts +452 -0
  48. package/src/handlers/component-ops.ts +269 -18
  49. package/src/handlers/markdown-ops.ts +7 -4
  50. package/src/handlers/request-utils.ts +1 -1
  51. package/src/handlers/source-writer.ts +4 -5
  52. package/src/index.ts +15 -10
  53. package/src/manifest-writer.ts +1 -1
  54. package/src/source-finder/cross-file-tracker.ts +1 -1
  55. package/src/source-finder/search-index.ts +1 -1
package/package.json CHANGED
@@ -14,7 +14,7 @@
14
14
  "directory": "packages/astro-cms"
15
15
  },
16
16
  "license": "Apache-2.0",
17
- "version": "0.1.2",
17
+ "version": "0.3.0",
18
18
  "module": "src/index.ts",
19
19
  "types": "src/index.ts",
20
20
  "type": "module",
@@ -4,6 +4,7 @@ import fs from 'node:fs/promises'
4
4
  import path from 'node:path'
5
5
  import { fileURLToPath } from 'node:url'
6
6
  import { getProjectRoot } from './config'
7
+ import { extractPropsFromSource, findComponentInvocationLine } from './handlers/component-ops'
7
8
  import { extractComponentName, processHtml } from './html-processor'
8
9
  import type { ManifestWriter } from './manifest-writer'
9
10
  import { generateComponentPreviews } from './preview-generator'
@@ -619,6 +620,32 @@ async function processFile(
619
620
  result.html = root.toString()
620
621
  }
621
622
 
623
+ // Populate component props from page source invocations
624
+ if (Object.keys(result.components).length > 0) {
625
+ const pageSourcePath = await findPageSource(pagePath)
626
+ if (pageSourcePath) {
627
+ try {
628
+ const pageContent = await fs.readFile(pageSourcePath, 'utf-8')
629
+ const pageLines = pageContent.split('\n')
630
+
631
+ // Track per-component-name occurrence counter
632
+ const occurrenceCounts = new Map<string, number>()
633
+
634
+ for (const comp of Object.values(result.components)) {
635
+ const idx = occurrenceCounts.get(comp.componentName) ?? 0
636
+ occurrenceCounts.set(comp.componentName, idx + 1)
637
+
638
+ const invLine = findComponentInvocationLine(pageLines, comp.componentName, idx)
639
+ if (invLine >= 0) {
640
+ comp.props = extractPropsFromSource(pageLines, invLine, comp.componentName)
641
+ }
642
+ }
643
+ } catch {
644
+ // Could not read page source — leave props empty
645
+ }
646
+ }
647
+ }
648
+
622
649
  // Remove CMS ID attributes from HTML for entries that were filtered out
623
650
  let finalHtml = result.html
624
651
  if (idsToRemove.length > 0) {
@@ -1,3 +1,4 @@
1
+ import { parse as parseBabel } from '@babel/parser'
1
2
  import fs from 'node:fs/promises'
2
3
  import path from 'node:path'
3
4
  import { getProjectRoot } from './config'
@@ -88,100 +89,148 @@ export class ComponentRegistry {
88
89
  }
89
90
 
90
91
  /**
91
- * Parse Props content and extract individual property definitions
92
- * Handles multi-line properties with nested types
92
+ * Parse Props content using @babel/parser AST for correct TypeScript handling.
93
+ * Wraps the content in a synthetic interface and walks TSPropertySignature nodes.
93
94
  */
94
95
  private parsePropsContent(propsContent: string): ComponentProp[] {
95
96
  const props: ComponentProp[] = []
96
- let i = 0
97
- const content = propsContent.trim()
98
-
99
- while (i < content.length) {
100
- // Skip whitespace and newlines
101
- while (i < content.length && /\s/.test(content[i] ?? '')) i++
102
- if (i >= content.length) break
103
-
104
- // Skip comments
105
- if (content[i] === '/' && content[i + 1] === '/') {
106
- // Skip to end of line
107
- while (i < content.length && content[i] !== '\n') i++
108
- continue
109
- }
110
-
111
- if (content[i] === '/' && content[i + 1] === '*') {
112
- // Skip block comment
113
- while (i < content.length - 1 && !(content[i] === '*' && content[i + 1] === '/')) i++
114
- i += 2
115
- continue
116
- }
117
-
118
- // Extract property name
119
- const nameStart = i
120
- while (i < content.length && /\w/.test(content[i] ?? '')) i++
121
- const name = content.substring(nameStart, i)
122
-
123
- if (!name) break
124
-
125
- // Skip whitespace
126
- while (i < content.length && /\s/.test(content[i] ?? '')) i++
127
-
128
- // Check for optional marker
129
- const optional = content[i] === '?'
130
- if (optional) i++
131
97
 
132
- // Skip whitespace
133
- while (i < content.length && /\s/.test(content[i] ?? '')) i++
134
-
135
- // Expect colon
136
- if (content[i] !== ':') break
137
- i++
98
+ // Wrap in an interface so Babel can parse it as valid TypeScript
99
+ const synthetic = `interface _Props {\n${propsContent}\n}`
100
+ let ast: ReturnType<typeof parseBabel>
101
+ try {
102
+ ast = parseBabel(synthetic, {
103
+ sourceType: 'module',
104
+ plugins: ['typescript'],
105
+ errorRecovery: true,
106
+ })
107
+ } catch {
108
+ return props
109
+ }
138
110
 
139
- // Skip whitespace
140
- while (i < content.length && /\s/.test(content[i] ?? '')) i++
141
-
142
- // Extract type (up to semicolon, handling nested braces)
143
- const typeStart = i
144
- let braceDepth = 0
145
- let angleDepth = 0
146
- while (i < content.length) {
147
- if (content[i] === '{') braceDepth++
148
- else if (content[i] === '}') braceDepth--
149
- else if (content[i] === '<') angleDepth++
150
- else if (content[i] === '>') angleDepth--
151
- else if (content[i] === ';' && braceDepth === 0 && angleDepth === 0) break
152
- i++
111
+ const interfaceNode = ast.program.body[0]
112
+ if (!interfaceNode || interfaceNode.type !== 'TSInterfaceDeclaration') return props
113
+
114
+ // Collect leading comments per line for JSDoc / inline descriptions
115
+ const lines = synthetic.split('\n')
116
+
117
+ for (const member of interfaceNode.body.body) {
118
+ if (member.type !== 'TSPropertySignature') continue
119
+ if (member.key.type !== 'Identifier') continue
120
+
121
+ const name = member.key.name
122
+ const optional = !!member.optional
123
+
124
+ // Reconstruct the type string from source text
125
+ let type = 'unknown'
126
+ if (member.typeAnnotation?.typeAnnotation) {
127
+ const ta = member.typeAnnotation.typeAnnotation
128
+ if (ta.loc) {
129
+ // Extract the type text directly from the synthetic source
130
+ const startLine = ta.loc.start.line - 1
131
+ const endLine = ta.loc.end.line - 1
132
+ if (startLine === endLine) {
133
+ type = lines[startLine]!.slice(ta.loc.start.column, ta.loc.end.column).trim()
134
+ } else {
135
+ const parts: string[] = []
136
+ for (let l = startLine; l <= endLine; l++) {
137
+ if (l === startLine) parts.push(lines[l]!.slice(ta.loc.start.column))
138
+ else if (l === endLine) parts.push(lines[l]!.slice(0, ta.loc.end.column))
139
+ else parts.push(lines[l]!)
140
+ }
141
+ type = parts.join('\n').trim()
142
+ }
143
+ } else {
144
+ type = this.typeAnnotationToString(ta)
145
+ }
153
146
  }
154
147
 
155
- const type = content.substring(typeStart, i).trim()
156
-
157
- // Skip the semicolon
158
- if (content[i] === ';') i++
148
+ // Look for description from comments
149
+ let description: string | undefined
159
150
 
160
- // Skip whitespace
161
- while (i < content.length && /[ \t]/.test(content[i] ?? '')) i++
151
+ // First, check for inline trailing comment on the property's source line
152
+ // (Babel can misattach these as leading comments of the next property)
153
+ if (member.loc) {
154
+ const lineIdx = member.loc.end.line - 1
155
+ const sourceLine = lines[lineIdx]
156
+ if (sourceLine) {
157
+ const commentMatch = sourceLine.match(/\/\/\s*(.+?)\s*$/)
158
+ if (commentMatch?.[1]) {
159
+ description = commentMatch[1]
160
+ }
161
+ }
162
+ }
162
163
 
163
- // Check for inline comment
164
- let description: string | undefined
165
- if (content[i] === '/' && content[i + 1] === '/') {
166
- i += 2
167
- const commentStart = i
168
- while (i < content.length && content[i] !== '\n') i++
169
- description = content.substring(commentStart, i).trim()
164
+ // If no inline comment, check for leading JSDoc or standalone line comments
165
+ if (!description && member.leadingComments && member.leadingComments.length > 0) {
166
+ const last = member.leadingComments[member.leadingComments.length - 1]!
167
+ if (last.type === 'CommentBlock') {
168
+ description = last.value
169
+ .split('\n')
170
+ .map((l: string) => l.replace(/^\s*\*\s?/, '').trim())
171
+ .filter(Boolean)
172
+ .join(' ')
173
+ } else if (last.type === 'CommentLine' && last.loc && member.loc) {
174
+ // Only use line comments on their own line (not inline on previous property)
175
+ const commentLineContent = lines[last.loc.start.line - 1]?.trim()
176
+ if (commentLineContent?.startsWith('//')) {
177
+ description = last.value.trim()
178
+ }
179
+ }
170
180
  }
171
181
 
172
182
  if (name && type) {
173
- props.push({
174
- name,
175
- type,
176
- required: !optional,
177
- description,
178
- })
183
+ props.push({ name, type, required: !optional, description })
179
184
  }
180
185
  }
181
186
 
182
187
  return props
183
188
  }
184
189
 
190
+ /**
191
+ * Fallback: convert a Babel TSType node to a human-readable string
192
+ */
193
+ private typeAnnotationToString(node: any): string {
194
+ switch (node.type) {
195
+ case 'TSStringKeyword':
196
+ return 'string'
197
+ case 'TSNumberKeyword':
198
+ return 'number'
199
+ case 'TSBooleanKeyword':
200
+ return 'boolean'
201
+ case 'TSAnyKeyword':
202
+ return 'any'
203
+ case 'TSVoidKeyword':
204
+ return 'void'
205
+ case 'TSNullKeyword':
206
+ return 'null'
207
+ case 'TSUndefinedKeyword':
208
+ return 'undefined'
209
+ case 'TSUnknownKeyword':
210
+ return 'unknown'
211
+ case 'TSNeverKeyword':
212
+ return 'never'
213
+ case 'TSObjectKeyword':
214
+ return 'object'
215
+ case 'TSArrayType':
216
+ return `${this.typeAnnotationToString(node.elementType)}[]`
217
+ case 'TSUnionType':
218
+ return node.types.map((t: any) => this.typeAnnotationToString(t)).join(' | ')
219
+ case 'TSIntersectionType':
220
+ return node.types.map((t: any) => this.typeAnnotationToString(t)).join(' & ')
221
+ case 'TSLiteralType':
222
+ if (node.literal.type === 'StringLiteral') return `'${node.literal.value}'`
223
+ return String(node.literal.value)
224
+ case 'TSTypeReference':
225
+ if (node.typeName?.type === 'Identifier') return node.typeName.name
226
+ return 'unknown'
227
+ case 'TSParenthesizedType':
228
+ return `(${this.typeAnnotationToString(node.typeAnnotation)})`
229
+ default:
230
+ return 'unknown'
231
+ }
232
+ }
233
+
185
234
  /**
186
235
  * Extract content between balanced braces after a pattern match
187
236
  * Properly handles nested objects
@@ -2,20 +2,18 @@ import { parse } from 'node-html-parser'
2
2
  import fs from 'node:fs/promises'
3
3
  import type { IncomingMessage, ServerResponse } from 'node:http'
4
4
  import path from 'node:path'
5
- import { handleInsertComponent, handleRemoveComponent } from './handlers/component-ops'
5
+ import { getProjectRoot } from './config'
6
+ import { handleAddArrayItem, handleRemoveArrayItem } from './handlers/array-ops'
6
7
  import {
7
- handleCreateMarkdown,
8
- handleGetMarkdownContent,
9
- handleUpdateMarkdown,
10
- } from './handlers/markdown-ops'
11
- import {
12
- handleCors,
13
- parseJsonBody,
14
- parseMultipartFile,
15
- readBody,
16
- sendError,
17
- sendJson,
18
- } from './handlers/request-utils'
8
+ extractPropsFromSource,
9
+ findComponentInvocationLine,
10
+ getPageFileCandidates,
11
+ handleInsertComponent,
12
+ handleRemoveComponent,
13
+ normalizeFilePath,
14
+ } from './handlers/component-ops'
15
+ import { handleCreateMarkdown, handleGetMarkdownContent, handleUpdateMarkdown } from './handlers/markdown-ops'
16
+ import { handleCors, parseJsonBody, parseMultipartFile, readBody, sendError, sendJson } from './handlers/request-utils'
19
17
  import { handleUpdate } from './handlers/source-writer'
20
18
  import { processHtml } from './html-processor'
21
19
  import type { ManifestWriter } from './manifest-writer'
@@ -188,7 +186,9 @@ export function createDevMiddleware(
188
186
  return originalWrite.call(res, chunk, encodingOrCb, cb)
189
187
  }
190
188
  if (chunk) {
191
- chunks!.push(typeof chunk === 'string' ? Buffer.from(chunk, typeof encodingOrCb === 'string' ? encodingOrCb as BufferEncoding : 'utf-8') : Buffer.from(chunk))
189
+ chunks!.push(
190
+ typeof chunk === 'string' ? Buffer.from(chunk, typeof encodingOrCb === 'string' ? encodingOrCb as BufferEncoding : 'utf-8') : Buffer.from(chunk),
191
+ )
192
192
  }
193
193
  if (typeof encodingOrCb === 'function') encodingOrCb()
194
194
  else if (typeof cb === 'function') cb()
@@ -272,6 +272,22 @@ async function handleCmsApiRoute(
272
272
  return
273
273
  }
274
274
 
275
+ // POST /_nua/cms/add-array-item
276
+ if (route === 'add-array-item' && req.method === 'POST') {
277
+ const body = await parseJsonBody<Parameters<typeof handleAddArrayItem>[0]>(req)
278
+ const result = await handleAddArrayItem(body, manifestWriter)
279
+ sendJson(res, result)
280
+ return
281
+ }
282
+
283
+ // POST /_nua/cms/remove-array-item
284
+ if (route === 'remove-array-item' && req.method === 'POST') {
285
+ const body = await parseJsonBody<Parameters<typeof handleRemoveArrayItem>[0]>(req)
286
+ const result = await handleRemoveArrayItem(body, manifestWriter)
287
+ sendJson(res, result)
288
+ return
289
+ }
290
+
275
291
  // GET /_nua/cms/markdown/content?filePath=...
276
292
  if (route === 'markdown/content' && req.method === 'GET') {
277
293
  const urlObj = new URL(req.url!, `http://${req.headers.host}`)
@@ -341,8 +357,14 @@ async function handleCmsApiRoute(
341
357
 
342
358
  // Validate file content type — allow images, videos, PDFs, and common web assets
343
359
  const allowedTypes = [
344
- 'image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/avif', 'image/x-icon',
345
- 'video/mp4', 'video/webm',
360
+ 'image/jpeg',
361
+ 'image/png',
362
+ 'image/gif',
363
+ 'image/webp',
364
+ 'image/avif',
365
+ 'image/x-icon',
366
+ 'video/mp4',
367
+ 'video/webm',
346
368
  'application/pdf',
347
369
  ]
348
370
  // Block SVG (can contain scripts) unless explicitly served with safe headers
@@ -435,6 +457,53 @@ async function processHtmlForDev(
435
457
  idGenerator,
436
458
  )
437
459
 
460
+ // Populate component props from source invocations
461
+ const projectRoot = getProjectRoot()
462
+ const fileCache = new Map<string, string[] | null>()
463
+ const readLines = async (filePath: string): Promise<string[] | null> => {
464
+ if (fileCache.has(filePath)) return fileCache.get(filePath)!
465
+ try {
466
+ const content = await fs.readFile(filePath, 'utf-8')
467
+ const lines = content.split('\n')
468
+ fileCache.set(filePath, lines)
469
+ return lines
470
+ } catch {
471
+ fileCache.set(filePath, null)
472
+ return null
473
+ }
474
+ }
475
+
476
+ for (const comp of Object.values(result.components)) {
477
+ let found = false
478
+
479
+ // Try invocationSourcePath first (may point to a layout, not the page)
480
+ if (comp.invocationSourcePath) {
481
+ const filePath = normalizeFilePath(comp.invocationSourcePath)
482
+ const lines = await readLines(path.resolve(projectRoot, filePath))
483
+ if (lines) {
484
+ const invLine = findComponentInvocationLine(lines, comp.componentName, comp.invocationIndex ?? 0)
485
+ if (invLine >= 0) {
486
+ comp.props = extractPropsFromSource(lines, invLine, comp.componentName)
487
+ found = true
488
+ }
489
+ }
490
+ }
491
+
492
+ // Fallback: search page source file candidates
493
+ if (!found) {
494
+ for (const candidate of getPageFileCandidates(pagePath)) {
495
+ const lines = await readLines(path.resolve(projectRoot, candidate))
496
+ if (lines) {
497
+ const invLine = findComponentInvocationLine(lines, comp.componentName, comp.invocationIndex ?? 0)
498
+ if (invLine >= 0) {
499
+ comp.props = extractPropsFromSource(lines, invLine, comp.componentName)
500
+ break
501
+ }
502
+ }
503
+ }
504
+ }
505
+ }
506
+
438
507
  // Build collection entry if this is a collection page
439
508
  let collectionEntry: CollectionEntry | undefined
440
509
  if (collectionInfo && mdContent) {
package/src/editor/api.ts CHANGED
@@ -247,6 +247,78 @@ export interface RemoveComponentResponse {
247
247
  error?: string
248
248
  }
249
249
 
250
+ export interface AddArrayItemResponse {
251
+ success: boolean
252
+ message?: string
253
+ sourceFile?: string
254
+ error?: string
255
+ }
256
+
257
+ export async function addArrayItem(
258
+ apiBase: string,
259
+ referenceComponentId: string,
260
+ position: 'before' | 'after',
261
+ props: Record<string, unknown>,
262
+ ): Promise<AddArrayItemResponse> {
263
+ const res = await fetchWithTimeout(`${apiBase}/add-array-item`, {
264
+ method: 'POST',
265
+ credentials: 'include',
266
+ headers: {
267
+ 'Content-Type': 'application/json',
268
+ },
269
+ body: JSON.stringify({
270
+ referenceComponentId,
271
+ position,
272
+ props,
273
+ meta: {
274
+ source: 'inline-editor',
275
+ url: window.location.href,
276
+ },
277
+ }),
278
+ })
279
+
280
+ if (!res.ok) {
281
+ const text = await res.text().catch(() => '')
282
+ throw new Error(`Add array item failed (${res.status}): ${text || res.statusText}`)
283
+ }
284
+
285
+ return res.json()
286
+ }
287
+
288
+ export interface RemoveArrayItemResponse {
289
+ success: boolean
290
+ message?: string
291
+ sourceFile?: string
292
+ error?: string
293
+ }
294
+
295
+ export async function removeArrayItem(
296
+ apiBase: string,
297
+ componentId: string,
298
+ ): Promise<RemoveArrayItemResponse> {
299
+ const res = await fetchWithTimeout(`${apiBase}/remove-array-item`, {
300
+ method: 'POST',
301
+ credentials: 'include',
302
+ headers: {
303
+ 'Content-Type': 'application/json',
304
+ },
305
+ body: JSON.stringify({
306
+ componentId,
307
+ meta: {
308
+ source: 'inline-editor',
309
+ url: window.location.href,
310
+ },
311
+ }),
312
+ })
313
+
314
+ if (!res.ok) {
315
+ const text = await res.text().catch(() => '')
316
+ throw new Error(`Remove array item failed (${res.status}): ${text || res.statusText}`)
317
+ }
318
+
319
+ return res.json()
320
+ }
321
+
250
322
  export async function removeComponent(
251
323
  apiBase: string,
252
324
  componentId: string,
@@ -382,7 +382,6 @@ export const AIChat = ({ callbacks }: AIChatProps) => {
382
382
  ? 'bg-cms-primary text-cms-primary-text self-end rounded-cms-lg rounded-br-cms-sm'
383
383
  : 'bg-white/10 text-white self-start rounded-cms-lg rounded-bl-cms-sm cms-markdown border border-white/10'
384
384
  }`}
385
-
386
385
  dangerouslySetInnerHTML={msg.role === 'assistant'
387
386
  ? { __html: renderMarkdown(msg.content) }
388
387
  : undefined}