@setzkasten-cms/astro-admin 1.4.2 → 1.5.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 (166) hide show
  1. package/dist/api-routes/_auth-guard.d.ts +27 -3
  2. package/dist/api-routes/_auth-guard.js +5 -2
  3. package/dist/api-routes/_dev-session-secret.d.ts +8 -0
  4. package/dist/api-routes/_dev-session-secret.js +8 -0
  5. package/dist/api-routes/_github-token.js +1 -1
  6. package/dist/api-routes/_role-resolver.js +6 -3
  7. package/dist/api-routes/_session-secret.d.ts +19 -0
  8. package/dist/api-routes/_session-secret.js +7 -0
  9. package/dist/api-routes/_session-signing.d.ts +45 -0
  10. package/dist/api-routes/_session-signing.js +8 -0
  11. package/dist/api-routes/_webhook-dispatcher.js +4 -4
  12. package/dist/api-routes/asset-proxy.js +1 -1
  13. package/dist/api-routes/auth-callback.js +12 -5
  14. package/dist/api-routes/auth-logout.d.ts +4 -4
  15. package/dist/api-routes/auth-logout.js +8 -2
  16. package/dist/api-routes/auth-session.d.ts +6 -0
  17. package/dist/api-routes/auth-session.js +19 -19
  18. package/dist/api-routes/auth-setzkasten-login.js +14 -7
  19. package/dist/api-routes/catalog-add.js +59 -17
  20. package/dist/api-routes/catalog-export.js +14 -4
  21. package/dist/api-routes/config.d.ts +10 -3
  22. package/dist/api-routes/config.js +26 -4
  23. package/dist/api-routes/deploy-hook.js +8 -8
  24. package/dist/api-routes/editors.d.ts +1 -1
  25. package/dist/api-routes/editors.js +5 -2
  26. package/dist/api-routes/github-proxy.js +30 -8
  27. package/dist/api-routes/global-config.js +6 -3
  28. package/dist/api-routes/history-rollback.js +31 -14
  29. package/dist/api-routes/history-version.js +8 -6
  30. package/dist/api-routes/history.js +5 -2
  31. package/dist/api-routes/icons-local.js +1 -1
  32. package/dist/api-routes/init-add-section.js +150 -48
  33. package/dist/api-routes/init-apply.js +56 -42
  34. package/dist/api-routes/init-migrate.js +43 -36
  35. package/dist/api-routes/init-scan-page.d.ts +1 -1
  36. package/dist/api-routes/init-scan-page.js +59 -13
  37. package/dist/api-routes/init-scan.js +22 -7
  38. package/dist/api-routes/migrate-to-multi.js +5 -2
  39. package/dist/api-routes/pages.js +15 -4
  40. package/dist/api-routes/section-add.js +68 -16
  41. package/dist/api-routes/section-commit-pending.js +70 -22
  42. package/dist/api-routes/section-delete.js +49 -14
  43. package/dist/api-routes/section-duplicate.js +65 -16
  44. package/dist/api-routes/section-prepare-copy.js +15 -2
  45. package/dist/api-routes/section-prepare.js +25 -4
  46. package/dist/api-routes/setup-github-app-bounce.js +15 -1
  47. package/dist/api-routes/setup-github-app-branches.js +9 -6
  48. package/dist/api-routes/setup-github-app-callback.js +24 -1
  49. package/dist/api-routes/setup-github-app-credentials.d.ts +27 -0
  50. package/dist/api-routes/setup-github-app-credentials.js +43 -0
  51. package/dist/api-routes/setup-github-app-installed.js +22 -1
  52. package/dist/api-routes/setup-github-app-repos.js +5 -2
  53. package/dist/api-routes/setup-github-app.d.ts +4 -0
  54. package/dist/api-routes/setup-github-app.js +19 -2
  55. package/dist/api-routes/updater-register.js +7 -1
  56. package/dist/api-routes/webhooks-status.js +5 -2
  57. package/dist/api-routes/webhooks-test.js +9 -8
  58. package/dist/api-routes/webhooks.js +12 -14
  59. package/dist/api-routes/websites-add.js +5 -2
  60. package/dist/api-routes/websites-remove.js +5 -2
  61. package/dist/{chunk-ZQDGGWJP.js → chunk-5KMGSFCZ.js} +2 -2
  62. package/dist/{chunk-RHJONMLK.js → chunk-CDXCYYQR.js} +222 -5
  63. package/dist/{chunk-NKDATSPA.js → chunk-DP6RTINQ.js} +1 -1
  64. package/dist/chunk-KENFINT4.js +76 -0
  65. package/dist/chunk-ONP6BRZO.js +47 -0
  66. package/dist/{chunk-INIWFKQ3.js → chunk-Q5HV47DW.js} +33 -19
  67. package/dist/chunk-QVCW6EF3.js +26 -0
  68. package/dist/{chunk-K22A4ZBS.js → chunk-UHI6323G.js} +293 -174
  69. package/dist/{chunk-AM4DZXXM.js → chunk-UJAFZEX2.js} +76 -9
  70. package/package.json +12 -6
  71. package/src/api-routes/__tests__/_session-signing.test.ts +114 -0
  72. package/src/api-routes/__tests__/_session-test-helper.ts +27 -0
  73. package/src/api-routes/__tests__/add-section-helpers.test.ts +91 -97
  74. package/src/api-routes/__tests__/auth-guard.test.ts +46 -7
  75. package/src/api-routes/__tests__/catalog-api.test.ts +14 -6
  76. package/src/api-routes/__tests__/commit-trailers.test.ts +5 -5
  77. package/src/api-routes/__tests__/deferred-operations.test.ts +9 -12
  78. package/src/api-routes/__tests__/deploy-hook.test.ts +3 -8
  79. package/src/api-routes/__tests__/feature-gate.test.ts +1 -1
  80. package/src/api-routes/__tests__/github-cache.test.ts +1 -1
  81. package/src/api-routes/__tests__/github-token.test.ts +1 -1
  82. package/src/api-routes/__tests__/global-config-theme.test.ts +4 -4
  83. package/src/api-routes/__tests__/history-rollback.test.ts +6 -3
  84. package/src/api-routes/__tests__/history.test.ts +9 -6
  85. package/src/api-routes/__tests__/init-scan-page-resolve-config.test.ts +11 -3
  86. package/src/api-routes/__tests__/migrate-to-multi.test.ts +5 -1
  87. package/src/api-routes/__tests__/pages-meta-store.test.ts +10 -5
  88. package/src/api-routes/__tests__/pages.test.ts +7 -2
  89. package/src/api-routes/__tests__/patch-page-file.test.ts +71 -19
  90. package/src/api-routes/__tests__/route-registry.test.ts +11 -18
  91. package/src/api-routes/__tests__/scan-page-helpers.test.ts +13 -10
  92. package/src/api-routes/__tests__/section-management.test.ts +28 -28
  93. package/src/api-routes/__tests__/setup-github-app-callback.test.ts +58 -16
  94. package/src/api-routes/__tests__/setup-github-app-repos.test.ts +4 -5
  95. package/src/api-routes/__tests__/setup-github-app.test.ts +27 -7
  96. package/src/api-routes/__tests__/storage-config-for-request.test.ts +83 -0
  97. package/src/api-routes/__tests__/updater-register.test.ts +230 -0
  98. package/src/api-routes/__tests__/webhook-signing.test.ts +1 -1
  99. package/src/api-routes/__tests__/webhooks.test.ts +19 -7
  100. package/src/api-routes/__tests__/websites-add.test.ts +2 -1
  101. package/src/api-routes/__tests__/websites-remove.test.ts +2 -1
  102. package/src/api-routes/_auth-guard.ts +47 -15
  103. package/src/api-routes/_commit-trailers.ts +3 -2
  104. package/src/api-routes/_dev-session-secret.ts +79 -0
  105. package/src/api-routes/_github-token.ts +1 -1
  106. package/src/api-routes/_pages-meta-store.ts +2 -2
  107. package/src/api-routes/_role-resolver.ts +7 -5
  108. package/src/api-routes/_session-secret.ts +46 -0
  109. package/src/api-routes/_session-signing.ts +135 -0
  110. package/src/api-routes/_vercel-origin.ts +2 -6
  111. package/src/api-routes/_webhook-dispatcher.ts +12 -16
  112. package/src/api-routes/_website-resolver.ts +3 -10
  113. package/src/api-routes/auth-callback.ts +9 -5
  114. package/src/api-routes/auth-login.ts +5 -3
  115. package/src/api-routes/auth-logout.ts +18 -1
  116. package/src/api-routes/auth-session.ts +13 -21
  117. package/src/api-routes/auth-setzkasten-login.ts +12 -9
  118. package/src/api-routes/catalog-add.ts +89 -31
  119. package/src/api-routes/catalog-export.ts +30 -10
  120. package/src/api-routes/config.ts +39 -6
  121. package/src/api-routes/deploy-hook.ts +13 -11
  122. package/src/api-routes/editors.ts +33 -22
  123. package/src/api-routes/github-proxy.ts +25 -11
  124. package/src/api-routes/global-config.ts +103 -18
  125. package/src/api-routes/history-rollback.ts +41 -14
  126. package/src/api-routes/history-version.ts +5 -6
  127. package/src/api-routes/history.ts +3 -3
  128. package/src/api-routes/icons-local.ts +2 -2
  129. package/src/api-routes/init-add-section.ts +218 -88
  130. package/src/api-routes/init-apply.ts +71 -56
  131. package/src/api-routes/init-migrate.ts +54 -48
  132. package/src/api-routes/init-scan-page.ts +77 -30
  133. package/src/api-routes/init-scan.ts +19 -11
  134. package/src/api-routes/pages.ts +16 -11
  135. package/src/api-routes/section-add.ts +98 -27
  136. package/src/api-routes/section-commit-pending.ts +87 -34
  137. package/src/api-routes/section-delete.ts +76 -27
  138. package/src/api-routes/section-duplicate.ts +95 -28
  139. package/src/api-routes/section-management.ts +3 -7
  140. package/src/api-routes/section-prepare-copy.ts +29 -8
  141. package/src/api-routes/section-prepare.ts +38 -10
  142. package/src/api-routes/setup-github-app-bounce.ts +7 -1
  143. package/src/api-routes/setup-github-app-branches.ts +6 -7
  144. package/src/api-routes/setup-github-app-callback.ts +18 -1
  145. package/src/api-routes/setup-github-app-credentials.ts +55 -0
  146. package/src/api-routes/setup-github-app-installed.ts +12 -1
  147. package/src/api-routes/setup-github-app-repos.ts +2 -3
  148. package/src/api-routes/setup-github-app.ts +14 -5
  149. package/src/api-routes/updater-check.ts +6 -4
  150. package/src/api-routes/updater-register.ts +34 -20
  151. package/src/api-routes/updater-transfer.ts +8 -6
  152. package/src/api-routes/updater-unbind.ts +14 -10
  153. package/src/api-routes/webhooks-test.ts +9 -11
  154. package/src/api-routes/webhooks.ts +15 -19
  155. package/src/init/__tests__/page-level.test.ts +279 -105
  156. package/src/init/__tests__/page-list-coverage.test.ts +70 -70
  157. package/src/init/__tests__/patcher-child-component.test.ts +126 -0
  158. package/src/init/__tests__/patcher-edge-cases.test.ts +47 -23
  159. package/src/init/__tests__/patcher-mixed-content-wrapper.test.ts +16 -6
  160. package/src/init/__tests__/patcher-page-mode.test.ts +30 -20
  161. package/src/init/__tests__/section-pipeline.test.ts +102 -16
  162. package/src/init/astro-config-patcher.ts +4 -18
  163. package/src/init/astro-detector.ts +2 -7
  164. package/src/init/astro-section-analyzer-v2.ts +475 -193
  165. package/src/init/field-label-enricher.ts +6 -6
  166. package/src/init/template-patcher-v2.ts +490 -56
@@ -11,13 +11,13 @@
11
11
  */
12
12
 
13
13
  import { parse } from '@astrojs/compiler'
14
- import { inferFields, type InferredSection, type InferredField } from '@setzkasten-cms/core/init'
14
+ import { type InferredField, type InferredSection, inferFields } from '@setzkasten-cms/core/init'
15
15
  import type {
16
16
  AnalyzerResult,
17
+ FieldPosition,
18
+ InnerFieldInfo,
17
19
  RepeatedGroup,
18
20
  RepeatedGroupInstance,
19
- InnerFieldInfo,
20
- FieldPosition,
21
21
  } from './analyzer-types.js'
22
22
  import { enrichFieldLabels } from './field-label-enricher.js'
23
23
 
@@ -43,7 +43,11 @@ interface AstAttr {
43
43
  position?: { start: { offset: number } }
44
44
  }
45
45
 
46
- function walkAst(node: AstNode, callback: (node: AstNode, parent: AstNode | null) => void, parent: AstNode | null = null): void {
46
+ function walkAst(
47
+ node: AstNode,
48
+ callback: (node: AstNode, parent: AstNode | null) => void,
49
+ parent: AstNode | null = null,
50
+ ): void {
47
51
  callback(node, parent)
48
52
  if (node.children) {
49
53
  for (const child of node.children) {
@@ -76,7 +80,7 @@ export async function analyzeAstroSection(
76
80
  const { frontmatter, template, templateOffset } = splitAstroFile(source)
77
81
 
78
82
  // Filter variables: skip prop values, fallbacks, and .map()-only sources
79
- const variables = extractFrontmatterVariables(frontmatter).filter(name => {
83
+ const variables = extractFrontmatterVariables(frontmatter).filter((name) => {
80
84
  const propUsageRegex = new RegExp(`\\w+={${name}}`)
81
85
  if (propUsageRegex.test(template)) return false
82
86
  const fallbackRegex = new RegExp(`(?:\\?\\?|\\|\\|)\\s*${name}\\b`)
@@ -101,7 +105,10 @@ export async function analyzeAstroSection(
101
105
  }
102
106
 
103
107
  // Extract template fields + repeated groups (Phase 1)
104
- const { fields: templateFields, repeatedGroups } = await extractTemplateFields(template, frontmatter)
108
+ const { fields: templateFields, repeatedGroups } = await extractTemplateFields(
109
+ template,
110
+ frontmatter,
111
+ )
105
112
 
106
113
  // Fix positions: extractTemplateFields already subtracts WRAPPER_OFFSET internally,
107
114
  // so positions are relative to the template string. Add templateOffset for full source.
@@ -146,17 +153,17 @@ export async function analyzeAstroSection(
146
153
  ;(field as any)._pos = usageMatch.index
147
154
  } else {
148
155
  const mapUsage = template.indexOf(`${field.key}.map(`)
149
- ;(field as any)._pos = mapUsage !== -1 ? mapUsage : Infinity
156
+ ;(field as any)._pos = mapUsage !== -1 ? mapUsage : Number.POSITIVE_INFINITY
150
157
  }
151
158
  }
152
159
 
153
160
  // Merge all fields and sort by template position
154
161
  const existingKeys = new Set(variableFields.map((f) => f.key))
155
- const allFields = [
156
- ...variableFields,
157
- ...templateFields.filter((f) => !existingKeys.has(f.key)),
158
- ]
159
- allFields.sort((a, b) => ((a as any)._pos ?? Infinity) - ((b as any)._pos ?? Infinity))
162
+ const allFields = [...variableFields, ...templateFields.filter((f) => !existingKeys.has(f.key))]
163
+ allFields.sort(
164
+ (a, b) =>
165
+ ((a as any)._pos ?? Number.POSITIVE_INFINITY) - ((b as any)._pos ?? Number.POSITIVE_INFINITY),
166
+ )
160
167
 
161
168
  const fields = allFields.map(({ ...field }) => {
162
169
  delete (field as any)._pos
@@ -188,7 +195,11 @@ export async function analyzeAstroSection(
188
195
  // Frontmatter utilities (unchanged from v1)
189
196
  // ---------------------------------------------------------------------------
190
197
 
191
- function splitAstroFile(source: string): { frontmatter: string; template: string; templateOffset: number } {
198
+ function splitAstroFile(source: string): {
199
+ frontmatter: string
200
+ template: string
201
+ templateOffset: number
202
+ } {
192
203
  if (!source.startsWith('---')) return { frontmatter: '', template: source, templateOffset: 0 }
193
204
  // Find closing --- marker (starts at beginning of a line after the opening ---)
194
205
  const endIdx = source.indexOf('\n---', 3)
@@ -210,11 +221,29 @@ function stripTemplateLiterals(source: string): string {
210
221
  i++
211
222
  let depth = 0
212
223
  while (i < source.length) {
213
- if (source[i] === '\\') { i += 2; continue }
214
- if (source[i] === '$' && source[i + 1] === '{') { depth++; i += 2; continue }
215
- if (source[i] === '{') { depth++; i++; continue }
216
- if (source[i] === '}' && depth > 0) { depth--; i++; continue }
217
- if (source[i] === '`' && depth === 0) { i++; break }
224
+ if (source[i] === '\\') {
225
+ i += 2
226
+ continue
227
+ }
228
+ if (source[i] === '$' && source[i + 1] === '{') {
229
+ depth++
230
+ i += 2
231
+ continue
232
+ }
233
+ if (source[i] === '{') {
234
+ depth++
235
+ i++
236
+ continue
237
+ }
238
+ if (source[i] === '}' && depth > 0) {
239
+ depth--
240
+ i++
241
+ continue
242
+ }
243
+ if (source[i] === '`' && depth === 0) {
244
+ i++
245
+ break
246
+ }
218
247
  i++
219
248
  }
220
249
  result += '``' // placeholder so surrounding code stays parseable
@@ -237,7 +266,8 @@ function extractFrontmatterVariables(frontmatter: string): string[] {
237
266
  const rhs = match[2]?.trim() ?? ''
238
267
  if (isInternalVariable(name)) continue
239
268
  // Skip exported declarations (e.g. "export const prerender = true")
240
- const charBefore = match.index > 0 ? stripped.slice(Math.max(0, match.index - 10), match.index) : ''
269
+ const charBefore =
270
+ match.index > 0 ? stripped.slice(Math.max(0, match.index - 10), match.index) : ''
241
271
  if (/export\s*$/.test(charBefore)) continue
242
272
  if (/\.\s*map\s*\(/.test(rhs) || /\w+\?\.\w+/.test(rhs)) continue
243
273
  if (/^\[/.test(rhs) && /^default/i.test(name)) continue
@@ -298,10 +328,26 @@ function extractFrontmatterValue(frontmatter: string, varName: string): unknown
298
328
 
299
329
  function isInternalVariable(name: string): boolean {
300
330
  const skip = new Set([
301
- 'Astro', 'props', 'data', 'class', 'className', 'style', 'id',
302
- 'slot', 'Fragment', 'Component', 'frontmatter', 'url', 'site',
303
- 'generator', 'redirect', 'response', 'request', 'cookies',
304
- 'params', 'slots',
331
+ 'Astro',
332
+ 'props',
333
+ 'data',
334
+ 'class',
335
+ 'className',
336
+ 'style',
337
+ 'id',
338
+ 'slot',
339
+ 'Fragment',
340
+ 'Component',
341
+ 'frontmatter',
342
+ 'url',
343
+ 'site',
344
+ 'generator',
345
+ 'redirect',
346
+ 'response',
347
+ 'request',
348
+ 'cookies',
349
+ 'params',
350
+ 'slots',
305
351
  ])
306
352
  return skip.has(name) || name.startsWith('_')
307
353
  }
@@ -323,7 +369,7 @@ function buildByteToCharMap(source: string): (byteOffset: number) => number {
323
369
  if (codePoint > 0xffff) charIdx++
324
370
  }
325
371
  map[byteIdx] = source.length
326
- return (offset) => offset <= 0 ? 0 : offset >= map.length ? source.length : map[offset]!
372
+ return (offset) => (offset <= 0 ? 0 : offset >= map.length ? source.length : map[offset]!)
327
373
  }
328
374
 
329
375
  function convertAstPositions(node: AstNode, b2c: (offset: number) => number): void {
@@ -346,11 +392,11 @@ function convertAstPositions(node: AstNode, b2c: (offset: number) => number): vo
346
392
  const WRAPPER_OFFSET = 8
347
393
 
348
394
  function nodeOffset(node: AstNode): number {
349
- return (node.position?.start?.offset ?? Infinity) - WRAPPER_OFFSET
395
+ return (node.position?.start?.offset ?? Number.POSITIVE_INFINITY) - WRAPPER_OFFSET
350
396
  }
351
397
 
352
398
  function nodeEnd(node: AstNode): number {
353
- return (node.position?.end?.offset ?? Infinity) - WRAPPER_OFFSET
399
+ return (node.position?.end?.offset ?? Number.POSITIVE_INFINITY) - WRAPPER_OFFSET
354
400
  }
355
401
 
356
402
  function getClassValue(node: AstNode): string {
@@ -368,7 +414,7 @@ function isAriaHidden(node: AstNode): boolean {
368
414
  return attr?.value === 'true'
369
415
  }
370
416
 
371
- function extractTextContent(node: AstNode, stripCmsBound: boolean = false): string {
417
+ function extractTextContent(node: AstNode, stripCmsBound = false): string {
372
418
  let text = ''
373
419
  if (node.type === 'text') {
374
420
  text += node.value ?? ''
@@ -387,8 +433,22 @@ function extractTextContent(node: AstNode, stripCmsBound: boolean = false): stri
387
433
 
388
434
  /** Tags that indicate inline formatting (bold, italic, links, code, color spans, etc.) */
389
435
  const INLINE_FORMATTING_TAGS = new Set([
390
- 'strong', 'b', 'em', 'i', 'mark', 'code', 'del', 'ins',
391
- 'sub', 'sup', 'a', 'abbr', 'cite', 'u', 's', 'small',
436
+ 'strong',
437
+ 'b',
438
+ 'em',
439
+ 'i',
440
+ 'mark',
441
+ 'code',
442
+ 'del',
443
+ 'ins',
444
+ 'sub',
445
+ 'sup',
446
+ 'a',
447
+ 'abbr',
448
+ 'cite',
449
+ 'u',
450
+ 's',
451
+ 'small',
392
452
  'span', // color/style spans inside text content
393
453
  ])
394
454
 
@@ -406,10 +466,14 @@ function hasInlineFormatting(node: AstNode): boolean {
406
466
  * Collect all class="..." attributes from an element subtree.
407
467
  * Returns them with a structural path (child index path) for cross-instance matching.
408
468
  */
409
- function collectClassAttrs(node: AstNode, path: string = '', source?: string): import('./analyzer-types.js').ClassAttrInfo[] {
469
+ function collectClassAttrs(
470
+ node: AstNode,
471
+ path = '',
472
+ source?: string,
473
+ ): import('./analyzer-types.js').ClassAttrInfo[] {
410
474
  const result: import('./analyzer-types.js').ClassAttrInfo[] = []
411
475
  if (node.type === 'element' && node.attributes && source) {
412
- const classAttr = node.attributes.find(a => a.name === 'class' && a.kind === 'quoted')
476
+ const classAttr = node.attributes.find((a) => a.name === 'class' && a.kind === 'quoted')
413
477
  if (classAttr && classAttr.value) {
414
478
  // The AST position points to the element start, not the attribute.
415
479
  // Find the actual `class="` within the opening tag.
@@ -494,7 +558,10 @@ function serializeNode(node: AstNode): string {
494
558
  }
495
559
 
496
560
  function camelToLabel(str: string): string {
497
- return str.replace(/([A-Z])/g, ' $1').replace(/^./, (s) => s.toUpperCase()).trim()
561
+ return str
562
+ .replace(/([A-Z])/g, ' $1')
563
+ .replace(/^./, (s) => s.toUpperCase())
564
+ .trim()
498
565
  }
499
566
 
500
567
  function inferInnerFieldType(name: string): InferredField['type'] {
@@ -502,8 +569,16 @@ function inferInnerFieldType(name: string): InferredField['type'] {
502
569
  if (/icon/.test(n)) return 'icon'
503
570
  if (/image|img|photo|avatar|logo|thumbnail|src/.test(n)) return 'image'
504
571
  if (/color|colour/.test(n)) return 'color'
505
- if (/count|amount|number|quantity|total|rating|score|percent|order|index|size|width|height/.test(n)) return 'number'
506
- if (/^is[A-Z]/.test(name) || /^has[A-Z]/.test(name) || /enabled|disabled|visible|hidden|active|checked|selected|highlight|accent|featured/.test(n)) return 'boolean'
572
+ if (
573
+ /count|amount|number|quantity|total|rating|score|percent|order|index|size|width|height/.test(n)
574
+ )
575
+ return 'number'
576
+ if (
577
+ /^is[A-Z]/.test(name) ||
578
+ /^has[A-Z]/.test(name) ||
579
+ /enabled|disabled|visible|hidden|active|checked|selected|highlight|accent|featured/.test(n)
580
+ )
581
+ return 'boolean'
507
582
  return 'text'
508
583
  }
509
584
 
@@ -511,7 +586,11 @@ function inferInnerFieldType(name: string): InferredField['type'] {
511
586
  // Inline array extraction (unchanged from v1)
512
587
  // ---------------------------------------------------------------------------
513
588
 
514
- function extractInlineArrayValues(arrayContent: string, isObjectArray: boolean, isStringArray: boolean): unknown[] {
589
+ function extractInlineArrayValues(
590
+ arrayContent: string,
591
+ isObjectArray: boolean,
592
+ isStringArray: boolean,
593
+ ): unknown[] {
515
594
  if (isStringArray || !isObjectArray) {
516
595
  const strings: string[] = []
517
596
  const strRegex = /['"]([^'"]+)['"]/g
@@ -528,7 +607,9 @@ function extractInlineArrayValues(arrayContent: string, isObjectArray: boolean,
528
607
  const obj = parseObjectLiteral(objStr)
529
608
  if (Object.keys(obj).length > 0) objects.push(obj)
530
609
  i = end + 1
531
- } else { i++ }
610
+ } else {
611
+ i++
612
+ }
532
613
  }
533
614
  return objects
534
615
  }
@@ -558,7 +639,10 @@ function parseObjectLiteral(objStr: string): Record<string, unknown> {
558
639
  while (i < objStr.length && /[\s,]/.test(objStr[i]!)) i++
559
640
  if (i >= objStr.length) break
560
641
  const keyMatch = objStr.slice(i).match(/^(\w+)\s*:\s*/)
561
- if (!keyMatch) { i++; continue }
642
+ if (!keyMatch) {
643
+ i++
644
+ continue
645
+ }
562
646
  const key = keyMatch[1]!
563
647
  i += keyMatch[0].length
564
648
  const ch = objStr[i]
@@ -587,11 +671,16 @@ function parseObjectLiteral(objStr: string): Record<string, unknown> {
587
671
  i = end + 1
588
672
  } else if (ch && /\d/.test(ch)) {
589
673
  const numMatch = objStr.slice(i).match(/^(\d+(?:\.\d+)?)/)
590
- if (numMatch) { obj[key] = Number(numMatch[1]); i += numMatch[0].length }
674
+ if (numMatch) {
675
+ obj[key] = Number(numMatch[1])
676
+ i += numMatch[0].length
677
+ }
591
678
  } else if (objStr.slice(i, i + 4) === 'true') {
592
- obj[key] = true; i += 4
679
+ obj[key] = true
680
+ i += 4
593
681
  } else if (objStr.slice(i, i + 5) === 'false') {
594
- obj[key] = false; i += 5
682
+ obj[key] = false
683
+ i += 5
595
684
  } else {
596
685
  while (i < objStr.length && objStr[i] !== ',') i++
597
686
  }
@@ -608,7 +697,7 @@ interface ExtractResult {
608
697
  repeatedGroups: RepeatedGroup[]
609
698
  }
610
699
 
611
- async function extractTemplateFields(template: string, frontmatter: string = ''): Promise<ExtractResult> {
700
+ async function extractTemplateFields(template: string, frontmatter = ''): Promise<ExtractResult> {
612
701
  const fields: Array<InferredField & { _pos: number }> = []
613
702
  const usedKeys = new Set<string>()
614
703
  const repeatedGroups: RepeatedGroup[] = []
@@ -632,11 +721,12 @@ async function extractTemplateFields(template: string, frontmatter: string = '')
632
721
  if (typeof field.defaultValue === 'string' && field.defaultValue.length >= 3) {
633
722
  finalPos = template.indexOf(field.defaultValue)
634
723
  } else if (Array.isArray(field.defaultValue) && field.defaultValue.length > 0) {
635
- const firstItem = typeof field.defaultValue[0] === 'string'
636
- ? field.defaultValue[0]
637
- : typeof field.defaultValue[0] === 'object' && field.defaultValue[0]
638
- ? Object.values(field.defaultValue[0])[0]
639
- : null
724
+ const firstItem =
725
+ typeof field.defaultValue[0] === 'string'
726
+ ? field.defaultValue[0]
727
+ : typeof field.defaultValue[0] === 'object' && field.defaultValue[0]
728
+ ? Object.values(field.defaultValue[0])[0]
729
+ : null
640
730
  if (typeof firstItem === 'string' && firstItem.length >= 2) {
641
731
  finalPos = template.indexOf(firstItem)
642
732
  }
@@ -648,7 +738,7 @@ async function extractTemplateFields(template: string, frontmatter: string = '')
648
738
  const keyMatch = keyRegex.exec(template)
649
739
  if (keyMatch) finalPos = keyMatch.index
650
740
  }
651
- fields.push({ ...field, _pos: finalPos === -1 ? Infinity : finalPos })
741
+ fields.push({ ...field, _pos: finalPos === -1 ? Number.POSITIVE_INFINITY : finalPos })
652
742
  }
653
743
  }
654
744
 
@@ -706,7 +796,9 @@ async function extractTemplateFields(template: string, frontmatter: string = '')
706
796
  repeatedElementGroups.push({ tag, instances: siblings })
707
797
  for (const inst of siblings) {
708
798
  const start = nodeOffset(inst)
709
- const end = inst.position?.end?.offset ? inst.position.end.offset - WRAPPER_OFFSET : start + 1
799
+ const end = inst.position?.end?.offset
800
+ ? inst.position.end.offset - WRAPPER_OFFSET
801
+ : start + 1
710
802
  repeatedElementRanges.push({ start, end })
711
803
  }
712
804
  }
@@ -716,7 +808,7 @@ async function extractTemplateFields(template: string, frontmatter: string = '')
716
808
  // Detects only tbody rows (not thead) so column headers are excluded.
717
809
  walkAst(ast, (node) => {
718
810
  if (node.type !== 'element' || node.name !== 'tbody') return
719
- const trRows = (node.children ?? []).filter(c => c.type === 'element' && c.name === 'tr')
811
+ const trRows = (node.children ?? []).filter((c) => c.type === 'element' && c.name === 'tr')
720
812
  if (trRows.length < 2) return
721
813
  repeatedElementGroups.push({ tag: 'tr', instances: trRows })
722
814
  for (const row of trRows) {
@@ -754,22 +846,38 @@ async function extractTemplateFields(template: string, frontmatter: string = '')
754
846
  while ((m = itemRe.exec(arrayFallbackMatch[1]!)) !== null) {
755
847
  items.push(m[1] ?? m[2] ?? m[3] ?? '')
756
848
  }
757
- const hasFormatting = items.some(s => /<[a-z]/.test(s))
758
- addField({
759
- key: fieldKey, type: 'array', label: camelToLabel(fieldKey), confidence: 'high',
760
- defaultValue: items.length > 0 ? items : undefined,
761
- options: { arrayItem: { type: 'text', ...(hasFormatting ? { formatting: true } : {}) } },
762
- }, nodeOffset(node))
849
+ const hasFormatting = items.some((s) => /<[a-z]/.test(s))
850
+ addField(
851
+ {
852
+ key: fieldKey,
853
+ type: 'array',
854
+ label: camelToLabel(fieldKey),
855
+ confidence: 'high',
856
+ defaultValue: items.length > 0 ? items : undefined,
857
+ options: {
858
+ arrayItem: { type: 'text', ...(hasFormatting ? { formatting: true } : {}) },
859
+ },
860
+ },
861
+ nodeOffset(node),
862
+ )
763
863
  } else {
764
864
  // String fallback or no fallback
765
865
  const strFallback = exprCode.match(/\?\?\s*(?:`([\s\S]*?)`|'([^']*)'|"([^"]*)")/)
766
- const defaultValue = strFallback ? (strFallback[1] ?? strFallback[2] ?? strFallback[3] ?? '').trim() : undefined
866
+ const defaultValue = strFallback
867
+ ? (strFallback[1] ?? strFallback[2] ?? strFallback[3] ?? '').trim()
868
+ : undefined
767
869
  const hasHtml = defaultValue ? /<[a-z]/.test(defaultValue) : false
768
- addField({
769
- key: fieldKey, type: 'text', label: camelToLabel(fieldKey), confidence: 'high',
770
- ...(defaultValue ? { defaultValue } : {}),
771
- ...(hasHtml ? { options: { formatting: true } } : {}),
772
- }, nodeOffset(node))
870
+ addField(
871
+ {
872
+ key: fieldKey,
873
+ type: 'text',
874
+ label: camelToLabel(fieldKey),
875
+ confidence: 'high',
876
+ ...(defaultValue ? { defaultValue } : {}),
877
+ ...(hasHtml ? { options: { formatting: true } } : {}),
878
+ },
879
+ nodeOffset(node),
880
+ )
773
881
  }
774
882
  cmsBoundOffsets.add(nodeOffset(node))
775
883
  }
@@ -779,21 +887,31 @@ async function extractTemplateFields(template: string, frontmatter: string = '')
779
887
  if (node.type !== 'element' && node.type !== 'component') return
780
888
  for (const attr of node.attributes ?? []) {
781
889
  if (attr.kind !== 'expression') continue
782
- const cmsAttrMatch = attr.value.match(/^\s*\w+\?\.\s*(\w+)\s*\?\?\s*(?:`([\s\S]*?)`|'([^']*)'|"([^"]*)")/)
890
+ const cmsAttrMatch = attr.value.match(
891
+ /^\s*\w+\?\.\s*(\w+)\s*\?\?\s*(?:`([\s\S]*?)`|'([^']*)'|"([^"]*)")/,
892
+ )
783
893
  if (cmsAttrMatch) {
784
894
  const fieldKey = cmsAttrMatch[1]!
785
895
  const fallback = (cmsAttrMatch[2] ?? cmsAttrMatch[3] ?? cmsAttrMatch[4] ?? '').trim()
786
- const pos = attr.position?.start?.offset ? attr.position.start.offset - WRAPPER_OFFSET : nodeOffset(node)
896
+ const pos = attr.position?.start?.offset
897
+ ? attr.position.start.offset - WRAPPER_OFFSET
898
+ : nodeOffset(node)
787
899
  // formatting: true when fallback contains HTML tags,
788
900
  // OR when the attribute is set:html without a fallback (content is always HTML then)
789
901
  const isSetHtml = attr.name === 'set:html'
790
902
  const fallbackHasHtml = fallback ? /<[a-z]/.test(fallback) : false
791
903
  const hasHtml = fallbackHasHtml || (isSetHtml && !fallback)
792
- addField({
793
- key: fieldKey, type: 'text', label: camelToLabel(fieldKey), confidence: 'high',
794
- ...(fallback ? { defaultValue: fallback } : {}),
795
- ...(hasHtml ? { options: { formatting: true } } : {}),
796
- }, pos)
904
+ addField(
905
+ {
906
+ key: fieldKey,
907
+ type: 'text',
908
+ label: camelToLabel(fieldKey),
909
+ confidence: 'high',
910
+ ...(fallback ? { defaultValue: fallback } : {}),
911
+ ...(hasHtml ? { options: { formatting: true } } : {}),
912
+ },
913
+ pos,
914
+ )
797
915
  cmsBoundOffsets.add(nodeOffset(node))
798
916
  }
799
917
  }
@@ -817,14 +935,25 @@ async function extractTemplateFields(template: string, frontmatter: string = '')
817
935
  if (!/uppercase|tracking-widest/.test(classVal)) return
818
936
  const text = extractTextContent(node, true).trim()
819
937
  if (text.length >= 2 && text.length <= 80) {
820
- addField({ key: 'overline', type: 'text', label: 'Overline', confidence: 'medium', defaultValue: text }, nodeOffset(node))
938
+ addField(
939
+ {
940
+ key: 'overline',
941
+ type: 'text',
942
+ label: 'Overline',
943
+ confidence: 'medium',
944
+ defaultValue: text,
945
+ },
946
+ nodeOffset(node),
947
+ )
821
948
  }
822
949
  })
823
950
 
824
951
  // ── 2. HEADINGS (h1-h6) ───────────────────────────────────────────────
825
952
  // Start from the count of already-registered heading* keys (from Section 0)
826
953
  // so new headings get non-colliding keys (heading3, heading4, ...).
827
- let headingCount = Array.from(usedKeys).filter(k => k === 'heading' || /^heading\d+$/.test(k)).length
954
+ let headingCount = Array.from(usedKeys).filter(
955
+ (k) => k === 'heading' || /^heading\d+$/.test(k),
956
+ ).length
828
957
  walkAst(ast, (node) => {
829
958
  if (node.type !== 'element') return
830
959
  if (!/^h[1-6]$/.test(node.name ?? '')) return
@@ -834,18 +963,26 @@ async function extractTemplateFields(template: string, frontmatter: string = '')
834
963
  headingCount++
835
964
  const headingOpts: Record<string, unknown> = { required: true }
836
965
  if (hasInlineFormatting(node)) headingOpts.formatting = true
837
- addField({
838
- key: numberedKey('heading', headingCount), type: 'text',
839
- label: numberedLabel('Heading', headingCount), confidence: 'high',
840
- defaultValue: text, options: headingOpts,
841
- }, nodeOffset(node))
966
+ addField(
967
+ {
968
+ key: numberedKey('heading', headingCount),
969
+ type: 'text',
970
+ label: numberedLabel('Heading', headingCount),
971
+ confidence: 'high',
972
+ defaultValue: text,
973
+ options: headingOpts,
974
+ },
975
+ nodeOffset(node),
976
+ )
842
977
  }
843
978
  })
844
979
 
845
980
  // ── 3. PARAGRAPHS / DESCRIPTION TEXT ───────────────────────────────────
846
981
  // Start from the count of already-registered description* keys (from Section 0)
847
982
  // so new description fields get non-colliding keys (description2, description3, ...).
848
- let descCount = Array.from(usedKeys).filter(k => k === 'description' || /^description\d+$/.test(k)).length
983
+ let descCount = Array.from(usedKeys).filter(
984
+ (k) => k === 'description' || /^description\d+$/.test(k),
985
+ ).length
849
986
  walkAst(ast, (node) => {
850
987
  if (node.type !== 'element') return
851
988
  if (node.name !== 'p' && node.name !== 'div') return
@@ -853,19 +990,28 @@ async function extractTemplateFields(template: string, frontmatter: string = '')
853
990
  const classVal = getClassValue(node)
854
991
  const serialized = serializeNode(node)
855
992
  if (/uppercase|tracking-widest/.test(classVal) && serialized.length < 250) return
856
- if (/text-2xl|text-3xl|text-\[11px\]|text-\[10px\]|text-\[9px\]|text-\[8px\]/.test(classVal)) return
993
+ if (/text-2xl|text-3xl|text-\[11px\]|text-\[10px\]|text-\[9px\]|text-\[8px\]/.test(classVal))
994
+ return
857
995
  if (node.name === 'div' && (containsElement(node, 'a') || containsElement(node, 'button'))) {
858
996
  // Allow mixed-text divs (callouts with inline links) — skip only pure-element
859
997
  // containers like nav/card wrappers that have no direct text nodes.
860
998
  const hasMixedText = (node.children ?? []).some(
861
- c => c.type === 'text' && (c.value ?? '').trim().length > 0,
999
+ (c) => c.type === 'text' && (c.value ?? '').trim().length > 0,
862
1000
  )
863
1001
  if (!hasMixedText) return
864
1002
  }
865
- if (node.name === 'div' && ['h1','h2','h3','h4','h5','h6'].some(h => containsElement(node, h))) return
1003
+ if (
1004
+ node.name === 'div' &&
1005
+ ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'].some((h) => containsElement(node, h))
1006
+ )
1007
+ return
866
1008
  if (node.name === 'div') {
867
- const contentChildren = (node.children ?? []).filter(c => c.type !== 'text' || (c.value ?? '').trim().length > 0)
868
- const hasOnlyElementChildren = contentChildren.length > 0 && contentChildren.every(c => c.type === 'element' || c.type === 'component')
1009
+ const contentChildren = (node.children ?? []).filter(
1010
+ (c) => c.type !== 'text' || (c.value ?? '').trim().length > 0,
1011
+ )
1012
+ const hasOnlyElementChildren =
1013
+ contentChildren.length > 0 &&
1014
+ contentChildren.every((c) => c.type === 'element' || c.type === 'component')
869
1015
  if (hasOnlyElementChildren) return
870
1016
  }
871
1017
  const text = extractTextContent(node, true).replace(/\s+/g, ' ').trim()
@@ -873,11 +1019,17 @@ async function extractTemplateFields(template: string, frontmatter: string = '')
873
1019
  descCount++
874
1020
  const descOpts: Record<string, unknown> = { multiline: true }
875
1021
  if (hasInlineFormatting(node)) descOpts.formatting = true
876
- addField({
877
- key: numberedKey('description', descCount), type: 'text',
878
- label: numberedLabel('Beschreibung', descCount), confidence: 'medium',
879
- defaultValue: text, options: descOpts,
880
- }, nodeOffset(node))
1022
+ addField(
1023
+ {
1024
+ key: numberedKey('description', descCount),
1025
+ type: 'text',
1026
+ label: numberedLabel('Beschreibung', descCount),
1027
+ confidence: 'medium',
1028
+ defaultValue: text,
1029
+ options: descOpts,
1030
+ },
1031
+ nodeOffset(node),
1032
+ )
881
1033
  })
882
1034
 
883
1035
  // ── 4. RICH TEXT (set:html) ────────────────────────────────────────────
@@ -893,11 +1045,17 @@ async function extractTemplateFields(template: string, frontmatter: string = '')
893
1045
  const strMatch = expr.match(/['"]([^'"]+)['"]/)
894
1046
  const fallbackMatch = expr.match(/\?\?\s*['"]([^'"]+)['"]/)
895
1047
  const value = fallbackMatch?.[1] ?? strMatch?.[1] ?? ''
896
- addField({
897
- key: numberedKey('richText', richCount), type: 'text',
898
- label: numberedLabel('Rich Text', richCount), confidence: 'high',
899
- defaultValue: value, options: { multiline: true, formatting: true },
900
- }, nodeOffset(node))
1048
+ addField(
1049
+ {
1050
+ key: numberedKey('richText', richCount),
1051
+ type: 'text',
1052
+ label: numberedLabel('Rich Text', richCount),
1053
+ confidence: 'high',
1054
+ defaultValue: value,
1055
+ options: { multiline: true, formatting: true },
1056
+ },
1057
+ nodeOffset(node),
1058
+ )
901
1059
  }
902
1060
  })
903
1061
 
@@ -912,11 +1070,16 @@ async function extractTemplateFields(template: string, frontmatter: string = '')
912
1070
  const text = extractTextContent(node, true).replace(/\s+/g, ' ').trim()
913
1071
  if (text.length >= 2 && text.length <= 60) {
914
1072
  ctaCount++
915
- addField({
916
- key: numberedKey('ctaText', ctaCount), type: 'text',
917
- label: numberedLabel('Button Text', ctaCount), confidence: 'medium',
918
- defaultValue: text,
919
- }, nodeOffset(node))
1073
+ addField(
1074
+ {
1075
+ key: numberedKey('ctaText', ctaCount),
1076
+ type: 'text',
1077
+ label: numberedLabel('Button Text', ctaCount),
1078
+ confidence: 'medium',
1079
+ defaultValue: text,
1080
+ },
1081
+ nodeOffset(node),
1082
+ )
920
1083
  }
921
1084
  })
922
1085
 
@@ -940,11 +1103,16 @@ async function extractTemplateFields(template: string, frontmatter: string = '')
940
1103
  if (!href) return
941
1104
  if (href.startsWith('#') || href.startsWith('javascript:')) return
942
1105
  linkCount++
943
- addField({
944
- key: numberedKey('ctaLink', linkCount), type: 'text',
945
- label: numberedLabel('Button Link', linkCount), confidence: 'medium',
946
- defaultValue: href,
947
- }, nodeOffset(node))
1106
+ addField(
1107
+ {
1108
+ key: numberedKey('ctaLink', linkCount),
1109
+ type: 'text',
1110
+ label: numberedLabel('Button Link', linkCount),
1111
+ confidence: 'medium',
1112
+ defaultValue: href,
1113
+ },
1114
+ nodeOffset(node),
1115
+ )
948
1116
  })
949
1117
 
950
1118
  // ── 7. IMAGES ──────────────────────────────────────────────────────────
@@ -965,22 +1133,32 @@ async function extractTemplateFields(template: string, frontmatter: string = '')
965
1133
  }
966
1134
  if (src) {
967
1135
  imgCount++
968
- addField({
969
- key: numberedKey('image', imgCount), type: 'image',
970
- label: numberedLabel('Bild', imgCount), confidence: 'high',
971
- defaultValue: { path: src.trim(), alt: '' },
972
- }, nodeOffset(node))
1136
+ addField(
1137
+ {
1138
+ key: numberedKey('image', imgCount),
1139
+ type: 'image',
1140
+ label: numberedLabel('Bild', imgCount),
1141
+ confidence: 'high',
1142
+ defaultValue: { path: src.trim(), alt: '' },
1143
+ },
1144
+ nodeOffset(node),
1145
+ )
973
1146
  }
974
1147
  }
975
1148
  if (tagName === 'img' || tagName === 'Image') {
976
1149
  const altAttr = getAttr(node, 'alt')
977
1150
  if (altAttr && altAttr.kind === 'quoted' && altAttr.value.trim().length >= 2) {
978
1151
  altCount++
979
- addField({
980
- key: numberedKey('imageAlt', altCount), type: 'text',
981
- label: numberedLabel('Bild Alt-Text', altCount), confidence: 'medium',
982
- defaultValue: altAttr.value.trim(),
983
- }, nodeOffset(node))
1152
+ addField(
1153
+ {
1154
+ key: numberedKey('imageAlt', altCount),
1155
+ type: 'text',
1156
+ label: numberedLabel('Bild Alt-Text', altCount),
1157
+ confidence: 'medium',
1158
+ defaultValue: altAttr.value.trim(),
1159
+ },
1160
+ nodeOffset(node),
1161
+ )
984
1162
  }
985
1163
  }
986
1164
  })
@@ -994,10 +1172,15 @@ async function extractTemplateFields(template: string, frontmatter: string = '')
994
1172
  if (svgSource.length < 100) return
995
1173
  if (parentNode && (parentNode.name === 'a' || parentNode.name === 'button')) return
996
1174
  iconCount++
997
- addField({
998
- key: numberedKey('icon', iconCount), type: 'icon',
999
- label: numberedLabel('Icon', iconCount), confidence: 'low',
1000
- }, nodeOffset(node))
1175
+ addField(
1176
+ {
1177
+ key: numberedKey('icon', iconCount),
1178
+ type: 'icon',
1179
+ label: numberedLabel('Icon', iconCount),
1180
+ confidence: 'low',
1181
+ },
1182
+ nodeOffset(node),
1183
+ )
1001
1184
  })
1002
1185
  let foundIconProp = false
1003
1186
  walkAst(ast, (node) => {
@@ -1016,14 +1199,18 @@ async function extractTemplateFields(template: string, frontmatter: string = '')
1016
1199
  walkAst(ast, (node) => {
1017
1200
  if (node.type !== 'expression') return
1018
1201
  const exprCode = (node.children ?? []).map((c) => c.value ?? '').join('')
1019
- const inlineArrayMatch = exprCode.match(/^\s*\[([\s\S]*?)\]\s*\.map\s*\(\s*\(?\s*(\{[^}]*\}|\w+)/)
1202
+ const inlineArrayMatch = exprCode.match(
1203
+ /^\s*\[([\s\S]*?)\]\s*\.map\s*\(\s*\(?\s*(\{[^}]*\}|\w+)/,
1204
+ )
1020
1205
  if (!inlineArrayMatch) return
1021
1206
  arrayCount++
1022
1207
  const arrayContent = inlineArrayMatch[1]!
1023
1208
  const callbackParam = inlineArrayMatch[2]!
1024
1209
  let objectKeys: string[] = []
1025
1210
  if (callbackParam.startsWith('{')) {
1026
- objectKeys = callbackParam.replace(/[{}]/g, '').split(',')
1211
+ objectKeys = callbackParam
1212
+ .replace(/[{}]/g, '')
1213
+ .split(',')
1027
1214
  .map((p: string) => p.trim().split(':')[0]!.trim())
1028
1215
  .filter((p: string) => p && !p.startsWith('...'))
1029
1216
  } else {
@@ -1042,25 +1229,39 @@ async function extractTemplateFields(template: string, frontmatter: string = '')
1042
1229
  }
1043
1230
  }
1044
1231
  const isObjectArray = objectKeys.length > 0
1045
- const isStringArray = /^\s*'[^']*'\s*,/.test(arrayContent) || /^\s*"[^"]*"\s*,/.test(arrayContent)
1232
+ const isStringArray =
1233
+ /^\s*'[^']*'\s*,/.test(arrayContent) || /^\s*"[^"]*"\s*,/.test(arrayContent)
1046
1234
  const arrayDefaultValue = extractInlineArrayValues(arrayContent, isObjectArray, isStringArray)
1047
1235
  if (isObjectArray && !isStringArray) {
1048
1236
  const innerFields: InferredField[] = objectKeys.map((prop: string) => ({
1049
- key: prop, type: inferInnerFieldType(prop), label: camelToLabel(prop), confidence: 'medium' as const,
1237
+ key: prop,
1238
+ type: inferInnerFieldType(prop),
1239
+ label: camelToLabel(prop),
1240
+ confidence: 'medium' as const,
1050
1241
  }))
1051
- addField({
1052
- key: numberedKey('items', arrayCount), type: 'array',
1053
- label: numberedLabel('Liste', arrayCount), confidence: 'high',
1054
- defaultValue: arrayDefaultValue,
1055
- options: { arrayItem: { type: 'object', fields: innerFields } },
1056
- }, nodeOffset(node))
1242
+ addField(
1243
+ {
1244
+ key: numberedKey('items', arrayCount),
1245
+ type: 'array',
1246
+ label: numberedLabel('Liste', arrayCount),
1247
+ confidence: 'high',
1248
+ defaultValue: arrayDefaultValue,
1249
+ options: { arrayItem: { type: 'object', fields: innerFields } },
1250
+ },
1251
+ nodeOffset(node),
1252
+ )
1057
1253
  } else {
1058
- addField({
1059
- key: numberedKey('items', arrayCount), type: 'array',
1060
- label: numberedLabel('Liste', arrayCount), confidence: 'high',
1061
- defaultValue: arrayDefaultValue,
1062
- options: { arrayItem: { type: 'text' } },
1063
- }, nodeOffset(node))
1254
+ addField(
1255
+ {
1256
+ key: numberedKey('items', arrayCount),
1257
+ type: 'array',
1258
+ label: numberedLabel('Liste', arrayCount),
1259
+ confidence: 'high',
1260
+ defaultValue: arrayDefaultValue,
1261
+ options: { arrayItem: { type: 'text' } },
1262
+ },
1263
+ nodeOffset(node),
1264
+ )
1064
1265
  }
1065
1266
  })
1066
1267
 
@@ -1079,7 +1280,7 @@ async function extractTemplateFields(template: string, frontmatter: string = '')
1079
1280
  walkAst(node, (n) => {
1080
1281
  if (hasMap) return
1081
1282
  if (n.type === 'expression') {
1082
- const code = (n.children ?? []).map(c => c.value ?? '').join('')
1283
+ const code = (n.children ?? []).map((c) => c.value ?? '').join('')
1083
1284
  if (/\.map\s*\(/.test(code)) hasMap = true
1084
1285
  }
1085
1286
  })
@@ -1137,12 +1338,19 @@ async function extractTemplateFields(template: string, frontmatter: string = '')
1137
1338
  }
1138
1339
  if (listItems.length < 1) return
1139
1340
  staticListCount++
1140
- addField({
1141
- key: numberedKey('items', arrayCount + staticListCount), type: 'array',
1142
- label: numberedLabel('Liste', arrayCount + staticListCount), confidence: 'high',
1143
- defaultValue: listItems,
1144
- options: { arrayItem: { type: 'text', ...(listHasFormatting ? { formatting: true } : {}) } },
1145
- }, nodeOffset(node))
1341
+ addField(
1342
+ {
1343
+ key: numberedKey('items', arrayCount + staticListCount),
1344
+ type: 'array',
1345
+ label: numberedLabel('Liste', arrayCount + staticListCount),
1346
+ confidence: 'high',
1347
+ defaultValue: listItems,
1348
+ options: {
1349
+ arrayItem: { type: 'text', ...(listHasFormatting ? { formatting: true } : {}) },
1350
+ },
1351
+ },
1352
+ nodeOffset(node),
1353
+ )
1146
1354
  })
1147
1355
 
1148
1356
  // ── 10. COMPONENT PROPS (content-bearing) ─────────────────────────────
@@ -1160,28 +1368,53 @@ async function extractTemplateFields(template: string, frontmatter: string = '')
1160
1368
  if (attr.kind === 'quoted') {
1161
1369
  const propName = attr.name
1162
1370
  const propValue = attr.value
1163
- if (/^(class|className|id|style|type|role|width|height|viewBox|fill|stroke|xmlns|d|cx|cy|r|rx|ry|x|y|x1|y1|x2|y2)$/.test(propName)) continue
1164
- if (/^(lang|language|filename|file|format|variant|size|loading|decoding|transition|client:.*)$/.test(propName)) continue
1371
+ if (
1372
+ /^(class|className|id|style|type|role|width|height|viewBox|fill|stroke|xmlns|d|cx|cy|r|rx|ry|x|y|x1|y1|x2|y2)$/.test(
1373
+ propName,
1374
+ )
1375
+ )
1376
+ continue
1377
+ if (
1378
+ /^(lang|language|filename|file|format|variant|size|loading|decoding|transition|client:.*)$/.test(
1379
+ propName,
1380
+ )
1381
+ )
1382
+ continue
1165
1383
  if (/^(aria-|data-)/.test(propName)) continue
1166
1384
  if (propValue.length < 2) continue
1167
1385
  if (propValue === 'true' || propValue === 'false' || /^\d+$/.test(propValue)) continue
1168
1386
  if (propValue.includes('/') && !propValue.includes(' ')) continue
1169
- addField({
1170
- key: propName,
1171
- type: propName === 'icon' ? 'icon' : propName === 'src' ? 'image' : 'text',
1172
- label: camelToLabel(propName), confidence: 'medium', defaultValue: propValue,
1173
- }, nodeOffset(node))
1387
+ addField(
1388
+ {
1389
+ key: propName,
1390
+ type: propName === 'icon' ? 'icon' : propName === 'src' ? 'image' : 'text',
1391
+ label: camelToLabel(propName),
1392
+ confidence: 'medium',
1393
+ defaultValue: propValue,
1394
+ },
1395
+ nodeOffset(node),
1396
+ )
1174
1397
  } else if (attr.kind === 'expression') {
1175
1398
  const propName = attr.name
1176
- if (/^(class|className|id|style|type|role|lang|language|filename|file|format|variant|size|loading|decoding)$/.test(propName)) continue
1399
+ if (
1400
+ /^(class|className|id|style|type|role|lang|language|filename|file|format|variant|size|loading|decoding)$/.test(
1401
+ propName,
1402
+ )
1403
+ )
1404
+ continue
1177
1405
  if (/^\s*\w+\s*$/.test(attr.value)) continue
1178
1406
  const fallbackMatch = attr.value.match(/\?\?\s*['"]([^'"]+)['"]/)
1179
1407
  if (fallbackMatch) {
1180
- addField({
1181
- key: propName,
1182
- type: propName === 'icon' ? 'icon' : propName === 'src' ? 'image' : 'text',
1183
- label: camelToLabel(propName), confidence: 'medium', defaultValue: fallbackMatch[1]!,
1184
- }, nodeOffset(node))
1408
+ addField(
1409
+ {
1410
+ key: propName,
1411
+ type: propName === 'icon' ? 'icon' : propName === 'src' ? 'image' : 'text',
1412
+ label: camelToLabel(propName),
1413
+ confidence: 'medium',
1414
+ defaultValue: fallbackMatch[1]!,
1415
+ },
1416
+ nodeOffset(node),
1417
+ )
1185
1418
  }
1186
1419
  }
1187
1420
  }
@@ -1209,7 +1442,8 @@ async function extractTemplateFields(template: string, frontmatter: string = '')
1209
1442
  if (/^(class|className|id|style|type|role)$/.test(name)) continue
1210
1443
  if (/^(aria-|data-)/.test(name)) continue
1211
1444
  if (attr.kind === 'quoted') {
1212
- allProps.add(name); item[name] = attr.value
1445
+ allProps.add(name)
1446
+ item[name] = attr.value
1213
1447
  } else if (attr.kind === 'expression') {
1214
1448
  allProps.add(name)
1215
1449
  const expr = attr.value.trim()
@@ -1224,20 +1458,45 @@ async function extractTemplateFields(template: string, frontmatter: string = '')
1224
1458
  if (Object.keys(item).length > 0) instanceValues.push(item)
1225
1459
  }
1226
1460
  if (allProps.size > 0) {
1227
- const cardKey = compName.replace(/([A-Z])/g, (_m: string, c: string, i: number) => i === 0 ? c.toLowerCase() : '_' + c.toLowerCase()).replace(/_/g, '') + 's'
1461
+ const cardKey =
1462
+ compName
1463
+ .replace(/([A-Z])/g, (_m: string, c: string, i: number) =>
1464
+ i === 0 ? c.toLowerCase() : '_' + c.toLowerCase(),
1465
+ )
1466
+ .replace(/_/g, '') + 's'
1228
1467
  if (!usedKeys.has(cardKey)) {
1229
1468
  const innerFields: InferredField[] = [...allProps].map((p) => {
1230
- const isArray = instanceValues.some(iv => Array.isArray(iv[p]))
1469
+ const isArray = instanceValues.some((iv) => Array.isArray(iv[p]))
1231
1470
  if (isArray) {
1232
- return { key: p, type: 'array' as const, label: camelToLabel(p), confidence: 'medium' as const, options: { arrayItem: { type: 'text' as const } } }
1471
+ return {
1472
+ key: p,
1473
+ type: 'array' as const,
1474
+ label: camelToLabel(p),
1475
+ confidence: 'medium' as const,
1476
+ options: { arrayItem: { type: 'text' as const } },
1477
+ }
1478
+ }
1479
+ return {
1480
+ key: p,
1481
+ type: inferInnerFieldType(p),
1482
+ label: camelToLabel(p),
1483
+ confidence: 'medium' as const,
1233
1484
  }
1234
- return { key: p, type: inferInnerFieldType(p), label: camelToLabel(p), confidence: 'medium' as const }
1235
1485
  })
1236
- addField({
1237
- key: cardKey, type: 'array', label: `${compName} Liste`, confidence: 'medium',
1238
- defaultValue: instanceValues.length > 0 ? instanceValues : undefined,
1239
- options: { arrayItem: { type: 'object', fields: innerFields } },
1240
- }, nodeOffset(instances[0]!))
1486
+ addField(
1487
+ {
1488
+ key: cardKey,
1489
+ type: 'array',
1490
+ label: `${compName} Liste`,
1491
+ confidence: 'medium',
1492
+ defaultValue: instanceValues.length > 0 ? instanceValues : undefined,
1493
+ options: {
1494
+ arrayItem: { type: 'object', fields: innerFields },
1495
+ sourceComponent: compName,
1496
+ },
1497
+ },
1498
+ nodeOffset(instances[0]!),
1499
+ )
1241
1500
  }
1242
1501
  }
1243
1502
  }
@@ -1249,7 +1508,7 @@ async function extractTemplateFields(template: string, frontmatter: string = '')
1249
1508
  const instanceCount = group.instances.length
1250
1509
 
1251
1510
  // Build instance bounds
1252
- const instanceBounds: RepeatedGroupInstance[] = group.instances.map(inst => ({
1511
+ const instanceBounds: RepeatedGroupInstance[] = group.instances.map((inst) => ({
1253
1512
  start: nodeOffset(inst),
1254
1513
  end: nodeEnd(inst),
1255
1514
  }))
@@ -1267,7 +1526,7 @@ async function extractTemplateFields(template: string, frontmatter: string = '')
1267
1526
 
1268
1527
  // Phase 1b: Use MAJORITY structure as canonical (most common tag sequence)
1269
1528
  // This avoids using an instance with optional elements as the template.
1270
- const signatures = instanceFingerprints.map(fp => fp.map(i => i.tag).join(','))
1529
+ const signatures = instanceFingerprints.map((fp) => fp.map((i) => i.tag).join(','))
1271
1530
  const sigCounts = new Map<string, number>()
1272
1531
  for (const sig of signatures) sigCounts.set(sig, (sigCounts.get(sig) ?? 0) + 1)
1273
1532
  const mostCommonSig = [...sigCounts.entries()].sort((a, b) => b[1] - a[1])[0]![0]
@@ -1351,7 +1610,8 @@ async function extractTemplateFields(template: string, frontmatter: string = '')
1351
1610
  // For links, also extract link text as a separate field
1352
1611
  if (isLink) {
1353
1612
  typeCounts['linkText'] = (typeCounts['linkText'] ?? 0) + 1
1354
- const ltKey = typeCounts['linkText'] === 1 ? 'linkText' : `linkText${typeCounts['linkText']}`
1613
+ const ltKey =
1614
+ typeCounts['linkText'] === 1 ? 'linkText' : `linkText${typeCounts['linkText']}`
1355
1615
  const ltPositions: Array<FieldPosition | null> = []
1356
1616
  const ltDefaults: unknown[] = []
1357
1617
 
@@ -1397,8 +1657,14 @@ async function extractTemplateFields(template: string, frontmatter: string = '')
1397
1657
  const tag = item.tag
1398
1658
  const isArray = tag === '__array__'
1399
1659
  const isLink = tag === 'a'
1400
- let fieldType: 'text' | 'array' | 'link' = isArray ? 'array' : isLink ? 'link' : 'text'
1401
- let keyBase = /^h[1-6]$/.test(tag) ? 'heading' : isLink ? 'link' : isArray ? 'list' : 'text'
1660
+ const fieldType: 'text' | 'array' | 'link' = isArray ? 'array' : isLink ? 'link' : 'text'
1661
+ const keyBase = /^h[1-6]$/.test(tag)
1662
+ ? 'heading'
1663
+ : isLink
1664
+ ? 'link'
1665
+ : isArray
1666
+ ? 'list'
1667
+ : 'text'
1402
1668
  typeCounts[keyBase] = (typeCounts[keyBase] ?? 0) + 1
1403
1669
  const key = typeCounts[keyBase] === 1 ? keyBase : `${keyBase}${typeCounts[keyBase]}`
1404
1670
 
@@ -1419,12 +1685,20 @@ async function extractTemplateFields(template: string, frontmatter: string = '')
1419
1685
 
1420
1686
  if (isLink) {
1421
1687
  typeCounts['linkText'] = (typeCounts['linkText'] ?? 0) + 1
1422
- const ltKey = typeCounts['linkText'] === 1 ? 'linkText' : `linkText${typeCounts['linkText']}`
1688
+ const ltKey =
1689
+ typeCounts['linkText'] === 1 ? 'linkText' : `linkText${typeCounts['linkText']}`
1423
1690
  const ltPositions: Array<FieldPosition | null> = new Array(instanceCount).fill(null)
1424
1691
  const ltDefaults: unknown[] = new Array(instanceCount).fill(null)
1425
1692
  ltPositions[ii] = positions[ii] ?? null
1426
1693
  ltDefaults[ii] = item.text || null
1427
- innerFields.push({ key: ltKey, type: 'text', tag: 'a', required: false, positions: ltPositions, defaultValues: ltDefaults })
1694
+ innerFields.push({
1695
+ key: ltKey,
1696
+ type: 'text',
1697
+ tag: 'a',
1698
+ required: false,
1699
+ positions: ltPositions,
1700
+ defaultValues: ltDefaults,
1701
+ })
1428
1702
  }
1429
1703
  }
1430
1704
  }
@@ -1451,7 +1725,7 @@ async function extractTemplateFields(template: string, frontmatter: string = '')
1451
1725
  }
1452
1726
 
1453
1727
  // Build inner field definitions for the field schema
1454
- const innerFieldDefs: InferredField[] = innerFields.map(f => {
1728
+ const innerFieldDefs: InferredField[] = innerFields.map((f) => {
1455
1729
  if (f.type === 'array') {
1456
1730
  return {
1457
1731
  key: f.key,
@@ -1463,32 +1737,36 @@ async function extractTemplateFields(template: string, frontmatter: string = '')
1463
1737
  }
1464
1738
  return {
1465
1739
  key: f.key,
1466
- type: f.type === 'link' ? 'text' as const : 'text' as const,
1740
+ type: f.type === 'link' ? ('text' as const) : ('text' as const),
1467
1741
  label: f.label ?? camelToLabel(f.key),
1468
1742
  confidence: 'medium' as const,
1469
1743
  }
1470
1744
  })
1471
1745
 
1472
1746
  // Add as top-level field
1473
- const groupLabel = group.tag === 'tr'
1474
- ? 'Tabellen-Zeilen'
1475
- : `${group.tag.charAt(0).toUpperCase() + group.tag.slice(1)} Liste`
1476
- addField({
1477
- key: fieldKey,
1478
- type: 'array',
1479
- label: groupLabel,
1480
- confidence: 'high',
1481
- defaultValue,
1482
- options: {
1483
- arrayItem: { type: 'object', fields: innerFieldDefs },
1484
- _repeatedTag: group.tag,
1485
- _instanceCount: instanceCount,
1486
- } as any,
1487
- }, instanceBounds[0]!.start)
1747
+ const groupLabel =
1748
+ group.tag === 'tr'
1749
+ ? 'Tabellen-Zeilen'
1750
+ : `${group.tag.charAt(0).toUpperCase() + group.tag.slice(1)} Liste`
1751
+ addField(
1752
+ {
1753
+ key: fieldKey,
1754
+ type: 'array',
1755
+ label: groupLabel,
1756
+ confidence: 'high',
1757
+ defaultValue,
1758
+ options: {
1759
+ arrayItem: { type: 'object', fields: innerFieldDefs },
1760
+ _repeatedTag: group.tag,
1761
+ _instanceCount: instanceCount,
1762
+ } as any,
1763
+ },
1764
+ instanceBounds[0]!.start,
1765
+ )
1488
1766
 
1489
1767
  // Collect class attributes per instance (AST-based, for dynamic class detection in patcher)
1490
1768
  // Positions are relative to `template` here; posAdjust is applied after extractTemplateFields returns
1491
- const classAttrs = group.instances.map(inst => collectClassAttrs(inst, '', template))
1769
+ const classAttrs = group.instances.map((inst) => collectClassAttrs(inst, '', template))
1492
1770
 
1493
1771
  // Store RepeatedGroup for the patcher
1494
1772
  repeatedGroups.push({
@@ -1510,7 +1788,7 @@ async function extractTemplateFields(template: string, frontmatter: string = '')
1510
1788
  // ---------------------------------------------------------------------------
1511
1789
 
1512
1790
  interface ContentItem {
1513
- tag: string // h2, p, span, a, __array__
1791
+ tag: string // h2, p, span, a, __array__
1514
1792
  depth: number
1515
1793
  node: AstNode
1516
1794
  text: string
@@ -1590,7 +1868,7 @@ function walkContentNodes(
1590
1868
  walkAst(child, (expr) => {
1591
1869
  if (mapSource) return
1592
1870
  if (expr.type !== 'expression') return
1593
- const code = (expr.children ?? []).map(c => c.value ?? '').join('')
1871
+ const code = (expr.children ?? []).map((c) => c.value ?? '').join('')
1594
1872
  const mapMatch = code.match(/(\w+)\.map\s*\(/)
1595
1873
  if (mapMatch) mapSource = mapMatch[1]
1596
1874
  })
@@ -1633,7 +1911,13 @@ function findMatchingItem(
1633
1911
  allCanonical: ContentItem[],
1634
1912
  consumed: Set<number>,
1635
1913
  ): ContentItem | null {
1636
- const result = findMatchingItemCore(targetFp, canonicalItem, canonicalIndex, allCanonical, consumed)
1914
+ const result = findMatchingItemCore(
1915
+ targetFp,
1916
+ canonicalItem,
1917
+ canonicalIndex,
1918
+ allCanonical,
1919
+ consumed,
1920
+ )
1637
1921
  if (result) consumed.add(result.idx)
1638
1922
  return result?.item ?? null
1639
1923
  }
@@ -1651,9 +1935,7 @@ function findMatchingItemPeek(
1651
1935
  // For peek, we look for the already-consumed link node (it was consumed by the link field)
1652
1936
  // So we search WITHOUT the consumed filter
1653
1937
  const tag = canonicalItem.tag
1654
- const candidates = targetFp
1655
- .map((item, idx) => ({ item, idx }))
1656
- .filter(c => c.item.tag === tag)
1938
+ const candidates = targetFp.map((item, idx) => ({ item, idx })).filter((c) => c.item.tag === tag)
1657
1939
  if (candidates.length === 0) return null
1658
1940
  if (candidates.length === 1) return candidates[0]!.item
1659
1941
  const canonicalRelPos = canonicalIndex / Math.max(allCanonical.length - 1, 1)
@@ -1675,7 +1957,7 @@ function findMatchingItemCore(
1675
1957
  const tag = canonicalItem.tag
1676
1958
  const candidates = targetFp
1677
1959
  .map((item, idx) => ({ item, idx }))
1678
- .filter(c => c.item.tag === tag && !consumed.has(c.idx))
1960
+ .filter((c) => c.item.tag === tag && !consumed.has(c.idx))
1679
1961
 
1680
1962
  if (candidates.length === 0) return null
1681
1963
  if (candidates.length === 1) return candidates[0]!