@setzkasten-cms/astro-admin 0.6.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 (49) hide show
  1. package/LICENSE +37 -0
  2. package/package.json +70 -0
  3. package/src/admin-page.astro +148 -0
  4. package/src/api-routes/__tests__/add-section-helpers.test.ts +383 -0
  5. package/src/api-routes/__tests__/catalog-api.test.ts +115 -0
  6. package/src/api-routes/__tests__/deferred-operations.test.ts +232 -0
  7. package/src/api-routes/__tests__/deploy-hook.test.ts +134 -0
  8. package/src/api-routes/__tests__/patch-page-file.test.ts +193 -0
  9. package/src/api-routes/__tests__/scan-page-helpers.test.ts +162 -0
  10. package/src/api-routes/__tests__/section-management.test.ts +284 -0
  11. package/src/api-routes/_storage-config.ts +54 -0
  12. package/src/api-routes/asset-proxy.ts +76 -0
  13. package/src/api-routes/auth-callback.ts +105 -0
  14. package/src/api-routes/auth-login.ts +87 -0
  15. package/src/api-routes/auth-logout.ts +9 -0
  16. package/src/api-routes/auth-session.ts +36 -0
  17. package/src/api-routes/catalog-add.ts +151 -0
  18. package/src/api-routes/catalog-export.ts +86 -0
  19. package/src/api-routes/catalog-helpers.ts +83 -0
  20. package/src/api-routes/catalog-list.ts +12 -0
  21. package/src/api-routes/config.ts +30 -0
  22. package/src/api-routes/deploy-hook.ts +69 -0
  23. package/src/api-routes/github-proxy.ts +111 -0
  24. package/src/api-routes/init-add-section.ts +511 -0
  25. package/src/api-routes/init-apply.ts +270 -0
  26. package/src/api-routes/init-migrate.ts +262 -0
  27. package/src/api-routes/init-scan-page.ts +336 -0
  28. package/src/api-routes/init-scan.ts +162 -0
  29. package/src/api-routes/pages.ts +17 -0
  30. package/src/api-routes/section-add.ts +189 -0
  31. package/src/api-routes/section-commit-pending.ts +147 -0
  32. package/src/api-routes/section-delete.ts +141 -0
  33. package/src/api-routes/section-duplicate.ts +144 -0
  34. package/src/api-routes/section-management.ts +95 -0
  35. package/src/api-routes/section-prepare-copy.ts +93 -0
  36. package/src/api-routes/section-prepare.ts +121 -0
  37. package/src/env.d.ts +7 -0
  38. package/src/init/__tests__/page-level.test.ts +1033 -0
  39. package/src/init/__tests__/page-list-coverage.test.ts +474 -0
  40. package/src/init/__tests__/patcher-edge-cases.test.ts +434 -0
  41. package/src/init/__tests__/patcher-page-mode.test.ts +272 -0
  42. package/src/init/__tests__/section-pipeline.test.ts +393 -0
  43. package/src/init/analyzer-types.ts +92 -0
  44. package/src/init/astro-config-patcher.ts +98 -0
  45. package/src/init/astro-detector.ts +207 -0
  46. package/src/init/astro-section-analyzer-v2.ts +1663 -0
  47. package/src/init/field-label-enricher.ts +72 -0
  48. package/src/init/template-patcher-v2.ts +1957 -0
  49. package/tsconfig.json +9 -0
@@ -0,0 +1,272 @@
1
+ /**
2
+ * Patcher page-mode bug regression tests.
3
+ *
4
+ * Covers 5 specific bugs that were fixed in template-patcher-v2:
5
+ * 1. patchArrayField always uses mapExpressions[0] (second array gets same expression)
6
+ * 2. patchSectionId puts id on Astro component instead of <section> in page mode
7
+ * 3. patchTextField only patches first matching text node
8
+ * 4. removeOldVarDeclarations leaves trailing semicolon
9
+ * 5. removeOldVarDeclarations causes TDZ (alias placed before skData declaration)
10
+ */
11
+
12
+ import { describe, it, expect } from 'vitest'
13
+ import { patchTemplateForFields } from '../../init/template-patcher-v2'
14
+ import type { PatchField } from '../../init/template-patcher-v2'
15
+
16
+ // ---------------------------------------------------------------------------
17
+ // Bug 1: patchArrayField always uses mapExpressions[0]
18
+ //
19
+ // When a page has two separate inline {[...].map()} arrays, both get patched
20
+ // to the same (first) expression. The second array's .map() ends up using the
21
+ // first array's skData key and defaultValue items.
22
+ // ---------------------------------------------------------------------------
23
+
24
+ describe('Bug 1: patchArrayField uses correct expression per field', () => {
25
+ // The bug: patchArrayField always picked mapExpressions[0], so both fields
26
+ // patched the SAME first .map() expression. Fix: content-based matching
27
+ // searches for the field's first defaultValue item within each expression.
28
+ // The fixture uses inline arrays (the bug only affected those, not var.map()).
29
+ const source = `---
30
+ export const prerender = true;
31
+ import BaseLayout from '../layouts/BaseLayout.astro';
32
+ ---
33
+
34
+ <BaseLayout>
35
+ <section class="py-16">
36
+ <div class="grid">
37
+ {[
38
+ { name: '@setzkasten-cms/core', desc: 'Schema, Feldtypen, Validation' },
39
+ { name: '@setzkasten-cms/ui', desc: 'React-UI: Provider, FormStore' },
40
+ ].map((pkg) => (
41
+ <div>
42
+ <h3>{pkg.name}</h3>
43
+ <p>{pkg.desc}</p>
44
+ </div>
45
+ ))}
46
+ </div>
47
+ <div class="grid">
48
+ {[
49
+ { title: 'Dependency Inversion', desc: 'UI haengt nur von Ports ab' },
50
+ { title: 'Schema-First', desc: 'Alles leitet sich vom TypeScript-Schema ab' },
51
+ ].map((principle) => (
52
+ <div>
53
+ <h3>{principle.title}</h3>
54
+ <p>{principle.desc}</p>
55
+ </div>
56
+ ))}
57
+ </div>
58
+ </section>
59
+ </BaseLayout>
60
+ `
61
+
62
+ const fields: PatchField[] = [
63
+ { key: 'items', type: 'array', defaultValue: [
64
+ { name: '@setzkasten-cms/core', desc: 'Schema, Feldtypen, Validation' },
65
+ { name: '@setzkasten-cms/ui', desc: 'React-UI: Provider, FormStore' },
66
+ ]},
67
+ { key: 'items2', type: 'array', defaultValue: [
68
+ { title: 'Dependency Inversion', desc: 'UI haengt nur von Ports ab' },
69
+ { title: 'Schema-First', desc: 'Alles leitet sich vom TypeScript-Schema ab' },
70
+ ]},
71
+ ]
72
+
73
+ it('should reference skData?.items for the first array', async () => {
74
+ const patched = await patchTemplateForFields(source, '_page_test', fields, [], { mode: 'page' })
75
+ expect(patched).toContain('skData?.items')
76
+ })
77
+
78
+ it('should reference skData?.items2 for the second array', async () => {
79
+ const patched = await patchTemplateForFields(source, '_page_test', fields, [], { mode: 'page' })
80
+ expect(patched).toContain('skData?.items2')
81
+ })
82
+
83
+ it('should not bleed defaultValue items across arrays', async () => {
84
+ const patched = await patchTemplateForFields(source, '_page_test', fields, [], { mode: 'page' })
85
+ // The patcher generates (skData?.items ?? []).map(...) for each array field.
86
+ // Both patterns must appear as distinct, non-overlapping substrings.
87
+ expect(patched).toContain('skData?.items2')
88
+ // skData?.items must appear as a standalone reference (not only as prefix of items2)
89
+ // Strip all occurrences of "items2" and check "items" is still present
90
+ expect(patched.replace(/items2/g, 'ITEMS2')).toContain('skData?.items')
91
+ })
92
+ })
93
+
94
+ // ---------------------------------------------------------------------------
95
+ // Bug 2: patchSectionId puts id on Astro component in page mode
96
+ //
97
+ // In page mode the patcher was adding id="section-X" to <BaseLayout> (an
98
+ // Astro component) rather than to the inner <section> element. The preview
99
+ // middleware looks for <section id="section-X"> in the rendered DOM.
100
+ // ---------------------------------------------------------------------------
101
+
102
+ describe('Bug 2: patchSectionId targets <section> not <BaseLayout> in page mode', () => {
103
+ const source = `---
104
+ import BaseLayout from '../layouts/BaseLayout.astro';
105
+ ---
106
+
107
+ <BaseLayout>
108
+ <section class="py-16">
109
+ <h1>Titel</h1>
110
+ </section>
111
+ </BaseLayout>
112
+ `
113
+
114
+ const fields: PatchField[] = [
115
+ { key: 'heading', type: 'text', defaultValue: 'Titel' },
116
+ ]
117
+
118
+ it('should add id="section-_page_test" to the <section> element', async () => {
119
+ const patched = await patchTemplateForFields(source, '_page_test', fields, [], { mode: 'page' })
120
+ expect(patched).toMatch(/<section[^>]*id="section-_page_test"/)
121
+ })
122
+
123
+ it('should NOT add id to <BaseLayout>', async () => {
124
+ const patched = await patchTemplateForFields(source, '_page_test', fields, [], { mode: 'page' })
125
+ expect(patched).not.toMatch(/<BaseLayout[^>]*id=/)
126
+ })
127
+ })
128
+
129
+ // ---------------------------------------------------------------------------
130
+ // Bug 3: patchTextField only patches first matching text node
131
+ //
132
+ // When multiple elements contain identical text (e.g. an <h1> and a <span>
133
+ // both say "Architektur"), only the first occurrence was being patched.
134
+ // Both elements should receive data-sk-field and set:html bindings.
135
+ // ---------------------------------------------------------------------------
136
+
137
+ describe('Bug 3: patchTextField patches all matching text nodes', () => {
138
+ const source = `---
139
+ import BaseLayout from '../layouts/BaseLayout.astro';
140
+ ---
141
+
142
+ <BaseLayout>
143
+ <section class="py-16">
144
+ <nav><span>Architektur</span></nav>
145
+ <h1>Architektur</h1>
146
+ </section>
147
+ </BaseLayout>
148
+ `
149
+
150
+ const fields: PatchField[] = [
151
+ { key: 'heading', type: 'text', defaultValue: 'Architektur' },
152
+ ]
153
+
154
+ it('should add data-sk-field to the <h1> element', async () => {
155
+ const patched = await patchTemplateForFields(source, '_page_test', fields, [], { mode: 'page' })
156
+ expect(patched).toMatch(/<h1[^>]*data-sk-field/)
157
+ })
158
+
159
+ it('should add data-sk-field to the <span> element', async () => {
160
+ const patched = await patchTemplateForFields(source, '_page_test', fields, [], { mode: 'page' })
161
+ expect(patched).toMatch(/<span[^>]*data-sk-field/)
162
+ })
163
+
164
+ it('should add set:html to both elements', async () => {
165
+ const patched = await patchTemplateForFields(source, '_page_test', fields, [], { mode: 'page' })
166
+ // Count occurrences of set:html — should appear at least twice
167
+ const matches = patched.match(/set:html/g) ?? []
168
+ expect(matches.length).toBeGreaterThanOrEqual(2)
169
+ })
170
+ })
171
+
172
+ // ---------------------------------------------------------------------------
173
+ // Bug 4: removeOldVarDeclarations leaves trailing semicolon
174
+ //
175
+ // After removing `const fields = [...]`, the array body was cleared but the
176
+ // closing `};` or standalone `;` was left as a stray line in the frontmatter.
177
+ // ---------------------------------------------------------------------------
178
+
179
+ describe('Bug 4: removeOldVarDeclarations does not leave stray semicolon', () => {
180
+ const source = `---
181
+ import BaseLayout from '../layouts/BaseLayout.astro';
182
+
183
+ const fields = ['Alpha', 'Beta', 'Gamma'];
184
+ ---
185
+
186
+ <BaseLayout>
187
+ <section class="py-16">
188
+ <ul>
189
+ {fields.map((f) => <li>{f}</li>)}
190
+ </ul>
191
+ </section>
192
+ </BaseLayout>
193
+ `
194
+
195
+ const fields: PatchField[] = [
196
+ { key: 'fields', type: 'array', defaultValue: ['Alpha', 'Beta', 'Gamma'] },
197
+ ]
198
+
199
+ it('should not contain a line that is only a semicolon in the frontmatter', async () => {
200
+ const patched = await patchTemplateForFields(source, '_page_test', fields, [], { mode: 'page' })
201
+
202
+ // Extract frontmatter
203
+ const fmMatch = patched.match(/^---\n([\s\S]*?)\n---/)
204
+ const frontmatter = fmMatch ? fmMatch[1]! : patched
205
+
206
+ const lines = frontmatter.split('\n').map(l => l.trim())
207
+ expect(lines).not.toContain(';')
208
+ })
209
+ })
210
+
211
+ // ---------------------------------------------------------------------------
212
+ // Bug 5: removeOldVarDeclarations causes TDZ
213
+ //
214
+ // When `const fields = skData?.fields ?? []` alias was inserted BEFORE
215
+ // `const skData = getSection(...)`, accessing `skData` threw a
216
+ // "Cannot access 'skData' before initialization" error at runtime.
217
+ // The alias must always appear AFTER the skData declaration.
218
+ // ---------------------------------------------------------------------------
219
+
220
+ describe('Bug 5: removeOldVarDeclarations alias placed after skData (no TDZ)', () => {
221
+ const source = `---
222
+ import BaseLayout from '../layouts/BaseLayout.astro';
223
+
224
+ const fields = ['Alpha', 'Beta', 'Gamma'];
225
+ ---
226
+
227
+ <BaseLayout>
228
+ <section class="py-16">
229
+ <p>Anzahl: {fields.length}</p>
230
+ <ul>
231
+ {fields.map((f) => <li>{f}</li>)}
232
+ </ul>
233
+ </section>
234
+ </BaseLayout>
235
+ `
236
+
237
+ const patchFields: PatchField[] = [
238
+ { key: 'fields', type: 'array', defaultValue: ['Alpha', 'Beta', 'Gamma'] },
239
+ ]
240
+
241
+ it('should place the fields alias AFTER the skData declaration', async () => {
242
+ const patched = await patchTemplateForFields(source, '_page_test', patchFields, [], { mode: 'page' })
243
+
244
+ const fmMatch = patched.match(/^---\n([\s\S]*?)\n---/)
245
+ const frontmatter = fmMatch ? fmMatch[1]! : patched
246
+
247
+ const skDataIndex = frontmatter.indexOf('const skData = getSection(')
248
+ const aliasIndex = frontmatter.indexOf('const fields = skData?.')
249
+
250
+ expect(skDataIndex, 'skData declaration should exist in frontmatter').toBeGreaterThan(-1)
251
+ expect(aliasIndex, 'fields alias should exist in frontmatter').toBeGreaterThan(-1)
252
+ expect(aliasIndex).toBeGreaterThan(skDataIndex)
253
+ })
254
+
255
+ it('should not contain a stray semicolon line in frontmatter', async () => {
256
+ const patched = await patchTemplateForFields(source, '_page_test', patchFields, [], { mode: 'page' })
257
+
258
+ const fmMatch = patched.match(/^---\n([\s\S]*?)\n---/)
259
+ const frontmatter = fmMatch ? fmMatch[1]! : patched
260
+
261
+ const lines = frontmatter.split('\n').map(l => l.trim())
262
+ expect(lines).not.toContain(';')
263
+ })
264
+
265
+ it('should preserve fields.length in the template', async () => {
266
+ const patched = await patchTemplateForFields(source, '_page_test', patchFields, [], { mode: 'page' })
267
+
268
+ // Extract template (after closing ---)
269
+ const templatePart = patched.split('\n---\n').slice(1).join('\n---\n')
270
+ expect(templatePart).toContain('fields.length')
271
+ })
272
+ })
@@ -0,0 +1,393 @@
1
+ /**
2
+ * Section Pipeline tests — migrated from test-sections-runner.mts
3
+ *
4
+ * Runs analyzer v2 + patcher v2 on every .astro file in test-sections/
5
+ * and validates:
6
+ * - Basic bindings (data-sk-field, skData, Astro.props, no [object Object])
7
+ * - Repeated groups (.map, instance reduction, CSS integrity)
8
+ * - Content JSON completeness
9
+ * - _classN alignment + tag consistency
10
+ * - Re-parse validity
11
+ * - data-sk-field binding coverage
12
+ * - Stale frontmatter var cleanup
13
+ */
14
+
15
+ import { describe, it, expect, beforeAll } from 'vitest'
16
+ import { readFileSync, readdirSync } from 'fs'
17
+ import { join, basename } from 'path'
18
+ import { analyzeAstroSection } from '../../init/astro-section-analyzer-v2'
19
+ import { patchTemplateForFields } from '../../init/template-patcher-v2'
20
+
21
+ // ---------------------------------------------------------------------------
22
+ // Test sections directory (root of monorepo)
23
+ // ---------------------------------------------------------------------------
24
+
25
+ const SECTIONS_DIR = join(import.meta.dirname!, '..', '..', '..', '..', '..', 'test-sections')
26
+ const files = readdirSync(SECTIONS_DIR).filter(f => f.endsWith('.astro')).sort()
27
+
28
+ // ---------------------------------------------------------------------------
29
+ // Helpers (ported from test-sections-runner.mts)
30
+ // ---------------------------------------------------------------------------
31
+
32
+ function extractClassValues(src: string): string[] {
33
+ const regex = /class="([^"]*)"/g
34
+ const classes: string[] = []
35
+ let m: RegExpExecArray | null
36
+ while ((m = regex.exec(src)) !== null) classes.push(m[1]!)
37
+ return classes
38
+ }
39
+
40
+ function checkCssIntegrity(
41
+ source: string,
42
+ patched: string,
43
+ groups: Array<{ tag: string; instances: Array<{ start: number; end: number }>; fields?: Array<{ key: string; defaultValues?: unknown[] }> }>,
44
+ ): { ok: boolean; lost: string[] } {
45
+ const lost: string[] = []
46
+ const dynamicClassValues = new Set<string>()
47
+ for (const g of groups) {
48
+ for (const f of (g.fields ?? [])) {
49
+ if (f.key.startsWith('_class') && f.defaultValues) {
50
+ for (const dv of f.defaultValues) {
51
+ if (typeof dv === 'string') dynamicClassValues.add(dv)
52
+ }
53
+ }
54
+ }
55
+ }
56
+ for (const g of groups) {
57
+ const originalClasses = new Set<string>()
58
+ for (const inst of g.instances) {
59
+ for (const cls of extractClassValues(source.slice(inst.start, inst.end))) {
60
+ originalClasses.add(cls)
61
+ }
62
+ }
63
+ const patchedClasses = new Set(extractClassValues(patched))
64
+ for (const cls of originalClasses) {
65
+ if (!patchedClasses.has(cls) && !dynamicClassValues.has(cls)) lost.push(cls)
66
+ }
67
+ }
68
+ return { ok: lost.length === 0, lost }
69
+ }
70
+
71
+ const SK_FIELD_NOT_NEEDED = new Set(['image', 'icon'])
72
+
73
+ function fieldNeedsSkBinding(field: { key: string; type: string }): boolean {
74
+ if (SK_FIELD_NOT_NEEDED.has(field.type)) return false
75
+ if (field.key.startsWith('_class')) return false
76
+ if (/link\d*$/i.test(field.key) && !/text/i.test(field.key)) return false
77
+ if (/alt\d*$/i.test(field.key)) return false
78
+ return true
79
+ }
80
+
81
+ function checkSkFieldBindings(
82
+ patched: string,
83
+ sectionKey: string,
84
+ fields: Array<{ key: string; type: string }>,
85
+ groups: Array<{ fieldKey: string; fields: Array<{ key: string; type: string }> }>,
86
+ ): { ok: boolean; missing: string[]; found: string[]; skipped: string[] } {
87
+ const missing: string[] = []
88
+ const found: string[] = []
89
+ const skipped: string[] = []
90
+ const repeatedFieldKeys = new Set<string>()
91
+ for (const g of groups) repeatedFieldKeys.add(g.fieldKey)
92
+
93
+ for (const field of fields) {
94
+ if (repeatedFieldKeys.has(field.key)) {
95
+ const mapBinding = `${sectionKey}.${field.key}.\${_i}`
96
+ if (patched.includes(mapBinding)) {
97
+ found.push(`${sectionKey}.${field.key}.*`)
98
+ } else {
99
+ missing.push(`${sectionKey}.${field.key}.* (container)`)
100
+ }
101
+ continue
102
+ }
103
+ if (!fieldNeedsSkBinding(field)) {
104
+ skipped.push(`${sectionKey}.${field.key} (${field.type})`)
105
+ continue
106
+ }
107
+ if (!patched.includes(`?.${field.key}`)) {
108
+ skipped.push(`${sectionKey}.${field.key} (not replaced)`)
109
+ continue
110
+ }
111
+ const binding = `${sectionKey}.${field.key}`
112
+ const hasStaticBinding = patched.includes(`data-sk-field="${binding}"`)
113
+ const hasExprBinding = patched.includes(`data-sk-field={\`${binding}`)
114
+ if (hasStaticBinding || hasExprBinding) {
115
+ found.push(binding)
116
+ } else {
117
+ missing.push(binding)
118
+ }
119
+ }
120
+
121
+ for (const g of groups) {
122
+ for (const innerField of g.fields) {
123
+ if (innerField.type === 'array') { skipped.push(`${sectionKey}.${g.fieldKey}.*.${innerField.key} (array)`); continue }
124
+ if (!fieldNeedsSkBinding(innerField)) { skipped.push(`${sectionKey}.${g.fieldKey}.*.${innerField.key} (${innerField.type})`); continue }
125
+ if (!patched.includes(`item.${innerField.key}`)) { skipped.push(`${sectionKey}.${g.fieldKey}.*.${innerField.key} (not replaced)`); continue }
126
+ const innerBinding = `${sectionKey}.${g.fieldKey}.\${_i}.${innerField.key}`
127
+ if (patched.includes(innerBinding)) {
128
+ found.push(`${sectionKey}.${g.fieldKey}.*.${innerField.key}`)
129
+ } else {
130
+ missing.push(`${sectionKey}.${g.fieldKey}.*.${innerField.key}`)
131
+ }
132
+ }
133
+ }
134
+ return { ok: missing.length === 0, missing, found, skipped }
135
+ }
136
+
137
+ // ---------------------------------------------------------------------------
138
+ // Test suite: one describe per .astro file in test-sections/
139
+ // ---------------------------------------------------------------------------
140
+
141
+ describe('Section Pipeline', () => {
142
+ for (const file of files) {
143
+ const name = basename(file, '.astro')
144
+ const filenameKey = name
145
+ .replace(/Section$/, '')
146
+ .replace(/([A-Z])/g, (m, c, i) => (i === 0 ? c.toLowerCase() : '-' + c.toLowerCase()))
147
+
148
+ describe(name, () => {
149
+ let source: string
150
+ let section: Awaited<ReturnType<typeof analyzeAstroSection>>
151
+ let patched: string
152
+ let groups: any[]
153
+ // Page-mode fixtures already have getSection() — detect and handle separately
154
+ let isPageMode: boolean
155
+ let sectionKey: string
156
+
157
+ beforeAll(async () => {
158
+ source = readFileSync(join(SECTIONS_DIR, file), 'utf-8')
159
+ isPageMode = source.includes("import { getSection } from 'setzkasten:content'")
160
+ const getSectionMatch = isPageMode ? source.match(/getSection\(['"]([^'"]+)['"]\)/) : null
161
+ sectionKey = getSectionMatch?.[1] ?? filenameKey
162
+ const options = isPageMode ? { mode: 'page' as const } : undefined
163
+ section = await analyzeAstroSection(source, sectionKey, name, `src/components/sections/${file}`, options)
164
+ groups = (section as any)._analyzerResult?.repeatedGroups ?? []
165
+ patched = await patchTemplateForFields(source, sectionKey, section.fields, groups, options)
166
+ })
167
+
168
+ it('should detect fields', () => {
169
+ expect(section.fields.length).toBeGreaterThan(0)
170
+ })
171
+
172
+ it('should contain data-sk-field after patching', () => {
173
+ if (patched === source) return // no changes needed
174
+ expect(patched).toContain('data-sk-field')
175
+ })
176
+
177
+ it('should contain skData bindings', () => {
178
+ if (patched === source) return
179
+ expect(patched).toContain('skData?.')
180
+ })
181
+
182
+ it('should contain Astro.props or getSection (page mode)', () => {
183
+ if (patched === source) return
184
+ if (isPageMode) {
185
+ expect(patched).toContain('getSection(')
186
+ } else {
187
+ expect(patched).toContain('Astro.props')
188
+ }
189
+ })
190
+
191
+ it('should not contain [object Object]', () => {
192
+ expect(patched).not.toContain('[object Object]')
193
+ })
194
+
195
+ it('should not have duplicate arrays from repeated groups as top-level fields', () => {
196
+ if (groups.length === 0) return
197
+ const innerArraySources = new Set<string>()
198
+ for (const g of groups) {
199
+ for (const f of g.fields) {
200
+ if (f.type === 'array') {
201
+ for (const pos of f.positions) {
202
+ if (pos?.source) innerArraySources.add(pos.source)
203
+ }
204
+ }
205
+ }
206
+ }
207
+ const duplicateArrays = section.fields.filter((f: any) =>
208
+ f.type === 'array' && innerArraySources.has(f.key),
209
+ )
210
+ expect(duplicateArrays, `Duplicate arrays: ${duplicateArrays.map((f: any) => f.key).join(', ')}`).toHaveLength(0)
211
+ })
212
+
213
+ it('should remove consumed frontmatter vars', () => {
214
+ if (patched === source || groups.length === 0) return
215
+ const fmEnd = patched.indexOf('---', patched.indexOf('---') + 3)
216
+ const patchedFm = fmEnd > 0 ? patched.slice(0, fmEnd) : ''
217
+ const staleVars: string[] = []
218
+ for (const g of groups) {
219
+ for (const f of g.fields) {
220
+ for (const pos of f.positions) {
221
+ if (pos?.source && patchedFm.includes(`const ${pos.source}`)) {
222
+ staleVars.push(pos.source)
223
+ }
224
+ }
225
+ }
226
+ }
227
+ expect(staleVars, `Stale vars: ${staleVars.join(', ')}`).toHaveLength(0)
228
+ })
229
+
230
+ it('should generate .map() for repeated groups', () => {
231
+ if (groups.length === 0) return
232
+ expect(patched).toContain('.map((item,')
233
+ })
234
+
235
+ it('should reduce instance count for repeated groups', () => {
236
+ if (groups.length === 0) return
237
+ for (const g of groups) {
238
+ const tagRegex = new RegExp(`<${g.tag}[\\s>]`, 'g')
239
+ const originalCount = (source.match(tagRegex) || []).length
240
+ const patchedCount = (patched.match(tagRegex) || []).length
241
+ expect(patchedCount, `<${g.tag}> count should decrease`).toBeLessThan(originalCount)
242
+ }
243
+ })
244
+
245
+ it('should preserve CSS classes', () => {
246
+ if (patched === source || groups.length === 0) return
247
+ const css = checkCssIntegrity(source, patched, groups)
248
+ expect(css.lost, `Lost CSS classes: ${css.lost.slice(0, 3).join(', ')}`).toHaveLength(0)
249
+ })
250
+
251
+ it('should produce complete content JSON', () => {
252
+ if (groups.length === 0) return
253
+ const issues: string[] = []
254
+
255
+ for (const g of groups) {
256
+ const arrayField = section.fields.find((f: any) => f.key === g.fieldKey)
257
+ if (!arrayField) { issues.push(`No top-level field for group ${g.fieldKey}`); continue }
258
+
259
+ const items = arrayField.defaultValue as Array<Record<string, unknown>> | undefined
260
+ if (!Array.isArray(items) || items.length === 0) {
261
+ issues.push(`${g.fieldKey} defaultValue is empty or not an array`)
262
+ continue
263
+ }
264
+
265
+ for (const innerField of g.fields) {
266
+ for (let ii = 0; ii < items.length && ii < innerField.defaultValues.length; ii++) {
267
+ const expected = innerField.defaultValues[ii]
268
+ if (expected == null) continue
269
+ const actual = items[ii]?.[innerField.key]
270
+ if (actual === undefined) {
271
+ issues.push(`${g.fieldKey}[${ii}].${innerField.key} missing`)
272
+ }
273
+ }
274
+ }
275
+
276
+ const itemRefRegex = /\bitem\.(\w+)/g
277
+ let refMatch: RegExpExecArray | null
278
+ const referencedKeys = new Set<string>()
279
+ while ((refMatch = itemRefRegex.exec(patched)) !== null) {
280
+ referencedKeys.add(refMatch[1]!)
281
+ }
282
+ for (const refKey of referencedKeys) {
283
+ if (['map', 'length', 'filter', 'forEach', 'join', 'slice'].includes(refKey)) continue
284
+ const hasInAnyItem = items.some(item => refKey in item)
285
+ if (!hasInAnyItem) {
286
+ issues.push(`{item.${refKey}} used in template but missing in content JSON`)
287
+ }
288
+ }
289
+ }
290
+
291
+ expect(issues, issues.slice(0, 5).join('\n')).toHaveLength(0)
292
+ })
293
+
294
+ it('should have correct _classN alignment', () => {
295
+ if (groups.length === 0) return
296
+ const issues: string[] = []
297
+
298
+ for (const g of groups) {
299
+ const classFields = g.fields.filter((f: any) => f.key.startsWith('_class'))
300
+ if (classFields.length === 0 || !g.classAttrs) continue
301
+
302
+ for (let instIdx = 0; instIdx < g.instances.length; instIdx++) {
303
+ const instAttrs = g.classAttrs[instIdx]
304
+ if (!instAttrs) continue
305
+
306
+ const originalClassCounts = new Map<string, number>()
307
+ for (const attr of instAttrs) {
308
+ originalClassCounts.set(attr.value, (originalClassCounts.get(attr.value) ?? 0) + 1)
309
+ }
310
+
311
+ const assignedClassCounts = new Map<string, string[]>()
312
+ for (const cf of classFields) {
313
+ const val = cf.defaultValues[instIdx]
314
+ if (typeof val !== 'string') continue
315
+ if (instIdx > 0 && val === cf.defaultValues[0]) continue
316
+ const arr = assignedClassCounts.get(val) ?? []
317
+ arr.push(cf.key)
318
+ assignedClassCounts.set(val, arr)
319
+ }
320
+
321
+ for (const [val, keys] of assignedClassCounts) {
322
+ const origCount = originalClassCounts.get(val) ?? 0
323
+ if (keys.length > origCount) {
324
+ const label = instIdx === 0 ? 'template' : `instance ${instIdx}`
325
+ issues.push(`${g.fieldKey} ${label}: "${val.slice(0, 40)}" assigned to [${keys.join(', ')}] but appears ${origCount}×`)
326
+ }
327
+ }
328
+ }
329
+ }
330
+
331
+ expect(issues, issues.slice(0, 3).join('\n')).toHaveLength(0)
332
+ })
333
+
334
+ it('should have correct _classN tag consistency', () => {
335
+ if (groups.length === 0) return
336
+ const issues: string[] = []
337
+
338
+ for (const g of groups) {
339
+ const classFields = g.fields.filter((f: any) => f.key.startsWith('_class'))
340
+ if (classFields.length === 0 || !g.classAttrs) continue
341
+
342
+ const classTagMap = new Map<string, string>()
343
+ const classTagRegex = /<(\w+)[\s][^>]*?class=\{item\.(_class\d+)\}/g
344
+ let ctm: RegExpExecArray | null
345
+ while ((ctm = classTagRegex.exec(patched)) !== null) {
346
+ classTagMap.set(ctm[2]!, ctm[1]!)
347
+ }
348
+
349
+ for (const cf of classFields) {
350
+ const expectedTag = classTagMap.get(cf.key)
351
+ if (!expectedTag) continue
352
+ const templateValue = cf.defaultValues[0]
353
+
354
+ for (let instIdx = 1; instIdx < g.instances.length; instIdx++) {
355
+ const instValue = cf.defaultValues[instIdx]
356
+ if (typeof instValue !== 'string') continue
357
+ if (instValue === templateValue) continue
358
+
359
+ const instAttrs = g.classAttrs[instIdx]
360
+ if (!instAttrs) continue
361
+
362
+ const matchingAttr = instAttrs.find((a: any) => a.value === instValue)
363
+ if (!matchingAttr) continue
364
+
365
+ const pathParts = matchingAttr.path.split('/')
366
+ const lastPart = pathParts[pathParts.length - 1]!
367
+ const actualTag = lastPart.replace(/:\d+$/, '') || g.tag
368
+
369
+ if (actualTag !== expectedTag) {
370
+ issues.push(`${cf.key} instance ${instIdx}: expected <${expectedTag}> but on <${actualTag}>`)
371
+ }
372
+ }
373
+ }
374
+ }
375
+
376
+ expect(issues, issues.slice(0, 3).join('\n')).toHaveLength(0)
377
+ })
378
+
379
+ it('should re-parse cleanly after patching', async () => {
380
+ if (patched === source) return
381
+ await expect(
382
+ analyzeAstroSection(patched, sectionKey + '__reparse', name, 'reparse-test'),
383
+ ).resolves.toBeDefined()
384
+ })
385
+
386
+ it('should have complete data-sk-field bindings', () => {
387
+ if (patched === source) return
388
+ const bindings = checkSkFieldBindings(patched, sectionKey, section.fields, groups)
389
+ expect(bindings.missing, `Missing bindings: ${bindings.missing.join(', ')}`).toHaveLength(0)
390
+ })
391
+ })
392
+ }
393
+ })