@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,1033 @@
1
+ /**
2
+ * Page-level content detection tests.
3
+ *
4
+ * Tests that the analyzer correctly detects inline content on pages
5
+ * (frontmatter arrays, headings, text) — not just imported section components.
6
+ *
7
+ * Covers:
8
+ * 1. Pure inline page (like /docs — no section imports, all content inline)
9
+ * 2. Mixed page (section imports + inline content)
10
+ * 3. Patcher mode:'page' (injects getSection instead of Astro.props)
11
+ */
12
+
13
+ import { describe, it, expect, beforeAll } from 'vitest'
14
+ import { readFileSync, readdirSync } from 'fs'
15
+ import { join, basename } from 'path'
16
+ import { analyzeAstroSection } from '../../init/astro-section-analyzer-v2'
17
+ import { patchTemplateForFields } from '../../init/template-patcher-v2'
18
+ import { extractSectionImports, extractLayoutImport } from '../../init/astro-detector'
19
+ import type { RepoFile } from '@setzkasten-cms/core/init'
20
+
21
+ const SECTIONS_DIR = join(import.meta.dirname!, '..', '..', '..', '..', '..', 'test-sections')
22
+ const PAGES_DIR = join(SECTIONS_DIR, 'pages')
23
+
24
+ // ---------------------------------------------------------------------------
25
+ // 1. Pure inline page (PageInlineContent.astro)
26
+ // Simulates a docs page with ALL content in frontmatter + template.
27
+ // ---------------------------------------------------------------------------
28
+
29
+ describe('Page-level: pure inline content', () => {
30
+ let source: string
31
+ let section: Awaited<ReturnType<typeof analyzeAstroSection>>
32
+ let patched: string
33
+ let groups: any[]
34
+
35
+ beforeAll(async () => {
36
+ source = readFileSync(join(SECTIONS_DIR, 'PageInlineContent.astro'), 'utf-8')
37
+ section = await analyzeAstroSection(source, '_page_docs', 'docsPage', 'src/pages/docs/index.astro', { mode: 'page' })
38
+ groups = (section as any)._analyzerResult?.repeatedGroups ?? []
39
+ patched = await patchTemplateForFields(source, '_page_docs', section.fields, groups, { mode: 'page' })
40
+ })
41
+
42
+ it('should detect fields from inline content', () => {
43
+ expect(section.fields.length).toBeGreaterThan(0)
44
+ })
45
+
46
+ it('should detect text fields (headings, paragraphs, overline)', () => {
47
+ const textFields = section.fields.filter(f => f.type === 'text')
48
+ expect(textFields.length).toBeGreaterThan(0)
49
+ // Should find the heading and description
50
+ const keys = textFields.map(f => f.key)
51
+ expect(keys).toContain('heading')
52
+ expect(keys).toContain('description')
53
+ })
54
+
55
+ it('should patch with data-sk-field attributes', () => {
56
+ expect(patched).toContain('data-sk-field')
57
+ })
58
+
59
+ it('should inject getSection import (not Astro.props) in page mode', () => {
60
+ expect(patched).toContain("import { getSection } from 'setzkasten:content'")
61
+ expect(patched).toContain("getSection('_page_docs')")
62
+ })
63
+
64
+ it('should NOT inject Astro.props in page mode', () => {
65
+ // The original source doesn't have Astro.props and page mode shouldn't add it
66
+ expect(patched).not.toContain('Astro.props')
67
+ })
68
+
69
+ it('should not contain [object Object]', () => {
70
+ expect(patched).not.toContain('[object Object]')
71
+ })
72
+
73
+ it('should re-parse cleanly after patching', async () => {
74
+ await expect(
75
+ analyzeAstroSection(patched, '_page_docs__reparse', 'docsPage', 'reparse-test'),
76
+ ).resolves.toBeDefined()
77
+ })
78
+
79
+ it('should use skData bindings', () => {
80
+ expect(patched).toContain('skData?.')
81
+ })
82
+ })
83
+
84
+ // ---------------------------------------------------------------------------
85
+ // 2. Mixed page (PageMixedContent.astro)
86
+ // Has imported section components AND inline content (testimonials).
87
+ // The analyzer should find the inline content fields.
88
+ // ---------------------------------------------------------------------------
89
+
90
+ describe('Page-level: mixed content (imports + inline)', () => {
91
+ let source: string
92
+ let section: Awaited<ReturnType<typeof analyzeAstroSection>>
93
+ let groups: any[]
94
+
95
+ beforeAll(async () => {
96
+ source = readFileSync(join(SECTIONS_DIR, 'PageMixedContent.astro'), 'utf-8')
97
+ // Analyze the page as a page-level section (same as init-scan-page.ts would)
98
+ section = await analyzeAstroSection(source, '_page_index', 'indexPage', 'src/pages/index.astro', { mode: 'page' })
99
+ groups = (section as any)._analyzerResult?.repeatedGroups ?? []
100
+ })
101
+
102
+ it('should detect inline content fields', () => {
103
+ expect(section.fields.length).toBeGreaterThan(0)
104
+ })
105
+
106
+ it('should detect inline text fields', () => {
107
+ const textFields = section.fields.filter(f => f.type === 'text')
108
+ expect(textFields.length).toBeGreaterThan(0)
109
+ })
110
+
111
+ it('should detect inline text fields (heading, paragraph)', () => {
112
+ const textFields = section.fields.filter(f => f.type === 'text')
113
+ expect(textFields.length).toBeGreaterThan(0)
114
+ // Should find "Was unsere Kunden sagen" or "Feedback von echten Nutzern."
115
+ const hasExpectedText = textFields.some(
116
+ f => typeof f.defaultValue === 'string' &&
117
+ (f.defaultValue.includes('Kunden') || f.defaultValue.includes('Feedback')),
118
+ )
119
+ expect(hasExpectedText, 'Should detect inline heading or paragraph text').toBe(true)
120
+ })
121
+
122
+ it('should NOT include imported component content as fields', () => {
123
+ // HeroSection and FeaturesSection are imports — their internal fields
124
+ // should NOT appear when analyzing the page template
125
+ // (imports are <HeroSection /> tags, their content is in separate files)
126
+ for (const field of section.fields) {
127
+ // If an import-sourced field leaked through, it would be detected as a component tag
128
+ expect(field.key).not.toMatch(/^hero/i)
129
+ expect(field.key).not.toMatch(/^feature/i)
130
+ }
131
+ })
132
+ })
133
+
134
+ // ---------------------------------------------------------------------------
135
+ // 3. Patcher mode comparison: 'component' vs 'page'
136
+ // Same source, different modes → different frontmatter injection.
137
+ // ---------------------------------------------------------------------------
138
+
139
+ describe('Patcher mode: component vs page', () => {
140
+ let source: string
141
+ let section: Awaited<ReturnType<typeof analyzeAstroSection>>
142
+ let groups: any[]
143
+
144
+ beforeAll(async () => {
145
+ source = readFileSync(join(SECTIONS_DIR, 'PageInlineContent.astro'), 'utf-8')
146
+ section = await analyzeAstroSection(source, '_page_docs', 'docsPage', 'src/pages/docs/index.astro', { mode: 'page' })
147
+ groups = (section as any)._analyzerResult?.repeatedGroups ?? []
148
+ })
149
+
150
+ it('component mode should inject Astro.props', async () => {
151
+ const patched = await patchTemplateForFields(source, '_page_docs', section.fields, groups)
152
+ expect(patched).toContain('Astro.props')
153
+ expect(patched).not.toContain("getSection('_page_docs')")
154
+ })
155
+
156
+ it('page mode should inject getSection', async () => {
157
+ const patched = await patchTemplateForFields(source, '_page_docs', section.fields, groups, { mode: 'page' })
158
+ expect(patched).toContain("import { getSection } from 'setzkasten:content'")
159
+ expect(patched).toContain("getSection('_page_docs')")
160
+ expect(patched).not.toContain('Astro.props')
161
+ })
162
+ })
163
+
164
+ // ---------------------------------------------------------------------------
165
+ // 4. Edge case: page with no detectable content
166
+ // A minimal page with only a layout wrapper should produce no fields.
167
+ // ---------------------------------------------------------------------------
168
+
169
+ describe('Page-level: minimal page (no content)', () => {
170
+ const minimalPage = `---
171
+ ---
172
+
173
+ <html><body><slot /></body></html>
174
+ `
175
+
176
+ it('should detect zero fields for a truly empty page', async () => {
177
+ const section = await analyzeAstroSection(minimalPage, '_page_empty', 'emptyPage', 'src/pages/empty.astro', { mode: 'page' })
178
+ expect(section.fields).toHaveLength(0)
179
+ })
180
+ })
181
+
182
+ // ---------------------------------------------------------------------------
183
+ // 5. Edge case: page already integrated (has getSection)
184
+ // ---------------------------------------------------------------------------
185
+
186
+ describe('Page-level: already integrated page', () => {
187
+ const integratedPage = `---
188
+ import BaseLayout from '../layouts/BaseLayout.astro';
189
+ import { getSection } from 'setzkasten:content';
190
+ const skData = getSection('_page_about');
191
+ ---
192
+
193
+ <BaseLayout title="About">
194
+ <h1 data-sk-field="_page_about.title" set:html={skData?.title}>Über uns</h1>
195
+ </BaseLayout>
196
+ `
197
+
198
+ it('should detect alreadyIntegrated flag', async () => {
199
+ const section = await analyzeAstroSection(integratedPage, '_page_about', 'aboutPage', 'src/pages/about.astro', { mode: 'page' })
200
+ expect(section.alreadyIntegrated).toBe(true)
201
+ })
202
+ })
203
+
204
+ // ---------------------------------------------------------------------------
205
+ // 6. Integration: full scan flow for mixed page
206
+ // Simulates what init-scan-page.ts does:
207
+ // 1. extractSectionImports() → finds imported section components
208
+ // 2. analyzeAstroSection() on page source → finds inline content
209
+ // Both must return results for a mixed page.
210
+ // ---------------------------------------------------------------------------
211
+
212
+ describe('Full scan flow: mixed page (imports + inline)', () => {
213
+ let source: string
214
+ let imports: ReturnType<typeof extractSectionImports>
215
+ let pageSection: Awaited<ReturnType<typeof analyzeAstroSection>>
216
+
217
+ beforeAll(async () => {
218
+ source = readFileSync(join(SECTIONS_DIR, 'PageMixedContent.astro'), 'utf-8')
219
+
220
+ // Simulate repo file tree (the section component files exist)
221
+ const repoFiles: RepoFile[] = [
222
+ { path: 'src/pages/index.astro', type: 'blob' },
223
+ { path: 'src/components/sections/HeroSection.astro', type: 'blob' },
224
+ { path: 'src/components/sections/FeaturesSection.astro', type: 'blob' },
225
+ { path: 'src/layouts/BaseLayout.astro', type: 'blob' },
226
+ ]
227
+
228
+ // Step 1: extractSectionImports (same as init-scan-page.ts line 95)
229
+ imports = extractSectionImports(source, 'src/pages/index.astro', repoFiles, '')
230
+
231
+ // Step 2: page-level analysis (same as init-scan-page.ts after the imports loop)
232
+ pageSection = await analyzeAstroSection(source, '_page_index', 'indexPage', 'src/pages/index.astro', { mode: 'page' })
233
+ })
234
+
235
+ it('should find imported section components via extractSectionImports', () => {
236
+ expect(imports.length).toBeGreaterThan(0)
237
+ const componentNames = imports.map(i => i.componentName)
238
+ expect(componentNames).toContain('HeroSection')
239
+ expect(componentNames).toContain('FeaturesSection')
240
+ })
241
+
242
+ it('should resolve section keys from imports', () => {
243
+ const keys = imports.map(i => i.sectionKey)
244
+ expect(keys).toContain('hero')
245
+ expect(keys).toContain('features')
246
+ })
247
+
248
+ it('should resolve file paths for imported sections', () => {
249
+ for (const imp of imports) {
250
+ expect(imp.resolvedPath, `${imp.componentName} should have resolvedPath`).toBeTruthy()
251
+ }
252
+ })
253
+
254
+ it('should detect inline content separately via page-level analysis', () => {
255
+ expect(pageSection.fields.length).toBeGreaterThan(0)
256
+ // Page-level should find the inline "Was unsere Kunden sagen" heading
257
+ const hasInlineContent = pageSection.fields.some(
258
+ f => typeof f.defaultValue === 'string' &&
259
+ (f.defaultValue.includes('Kunden') || f.defaultValue.includes('Feedback')),
260
+ )
261
+ expect(hasInlineContent, 'Page-level should find inline text content').toBe(true)
262
+ })
263
+
264
+ it('should produce non-overlapping results (imports vs inline)', () => {
265
+ // Import keys are "hero", "features" — page-level key is "_page_index"
266
+ // They should never collide
267
+ const importKeys = new Set(imports.map(i => i.sectionKey))
268
+ expect(importKeys.has(pageSection.key)).toBe(false)
269
+ })
270
+
271
+ it('should cover all content: imports + page-level together', () => {
272
+ // A mixed page produces:
273
+ // - N imported sections (each analyzed separately from their component source)
274
+ // - 1 page-level section (inline content analyzed from page source)
275
+ const totalSections = imports.length + 1 // +1 for page-level
276
+ expect(totalSections).toBeGreaterThanOrEqual(3) // 2 imports + 1 page-level
277
+ })
278
+ })
279
+
280
+ // ---------------------------------------------------------------------------
281
+ // 7. Integration: pure inline page has no section imports
282
+ // extractSectionImports should return empty, but page-level finds fields.
283
+ // ---------------------------------------------------------------------------
284
+
285
+ describe('Full scan flow: pure inline page (no imports)', () => {
286
+ let source: string
287
+ let imports: ReturnType<typeof extractSectionImports>
288
+ let pageSection: Awaited<ReturnType<typeof analyzeAstroSection>>
289
+
290
+ beforeAll(async () => {
291
+ source = readFileSync(join(SECTIONS_DIR, 'PageInlineContent.astro'), 'utf-8')
292
+
293
+ const repoFiles: RepoFile[] = [
294
+ { path: 'src/pages/docs/index.astro', type: 'blob' },
295
+ { path: 'src/layouts/BaseLayout.astro', type: 'blob' },
296
+ ]
297
+
298
+ imports = extractSectionImports(source, 'src/pages/docs/index.astro', repoFiles, '')
299
+ pageSection = await analyzeAstroSection(source, '_page_docs', 'docsPage', 'src/pages/docs/index.astro', { mode: 'page' })
300
+ })
301
+
302
+ it('should find zero section imports for a pure inline page', () => {
303
+ expect(imports).toHaveLength(0)
304
+ })
305
+
306
+ it('should still detect content via page-level analysis', () => {
307
+ expect(pageSection.fields.length).toBeGreaterThan(0)
308
+ })
309
+
310
+ it('should detect heading and description fields', () => {
311
+ const keys = pageSection.fields.map(f => f.key)
312
+ expect(keys).toContain('heading')
313
+ expect(keys).toContain('description')
314
+ })
315
+ })
316
+
317
+ // ---------------------------------------------------------------------------
318
+ // 7b. Page-level: .map()-only arrays must be detected on pages
319
+ // In component mode, .map()-only arrays are filtered (they become
320
+ // repeatedGroup inner fields). In page mode, they are standalone content.
321
+ // ---------------------------------------------------------------------------
322
+
323
+ describe('Page-level: .map()-only arrays detected', () => {
324
+ let section: Awaited<ReturnType<typeof analyzeAstroSection>>
325
+ let sectionComponent: Awaited<ReturnType<typeof analyzeAstroSection>>
326
+
327
+ beforeAll(async () => {
328
+ const source = readFileSync(join(SECTIONS_DIR, 'PageInlineContent.astro'), 'utf-8')
329
+ // Page mode: .map()-only arrays should be kept
330
+ section = await analyzeAstroSection(source, '_page_docs', 'docsPage', 'src/pages/docs/index.astro', { mode: 'page' })
331
+ // Component mode (default): .map()-only arrays should be filtered
332
+ sectionComponent = await analyzeAstroSection(source, 'docs', 'DocsSection', 'src/components/docs.astro')
333
+ })
334
+
335
+ it('should detect the sections array on page mode', () => {
336
+ const arrayFields = section.fields.filter(f => f.type === 'array')
337
+ expect(arrayFields.length).toBeGreaterThan(0)
338
+ })
339
+
340
+ it('should detect "sections" variable as a field on page mode', () => {
341
+ const keys = section.fields.map(f => f.key)
342
+ expect(keys).toContain('sections')
343
+ })
344
+
345
+ it('should NOT detect "sections" variable in component mode (.map()-only filter)', () => {
346
+ const keys = sectionComponent.fields.map(f => f.key)
347
+ expect(keys).not.toContain('sections')
348
+ })
349
+ })
350
+
351
+ // ---------------------------------------------------------------------------
352
+ // 8. Regression: export const prerender must not be removed by patcher
353
+ // Bug: removeOldVarDeclarations matched "const prerender = true" inside
354
+ // "export const prerender = true" and removed it, leaving "export " behind.
355
+ // ---------------------------------------------------------------------------
356
+
357
+ describe('Patcher safety: export const prerender preserved', () => {
358
+ const pageWithPrerender = `---
359
+ export const prerender = true;
360
+
361
+ import BaseLayout from '../layouts/BaseLayout.astro';
362
+
363
+ const title = 'Meine Seite';
364
+ ---
365
+
366
+ <BaseLayout>
367
+ <h1>{title}</h1>
368
+ <p>Beschreibung der Seite.</p>
369
+ </BaseLayout>
370
+ `
371
+
372
+ it('should not remove export const prerender', async () => {
373
+ const section = await analyzeAstroSection(pageWithPrerender, '_page_test', 'testPage', 'test.astro', { mode: 'page' })
374
+ const patched = await patchTemplateForFields(pageWithPrerender, '_page_test', section.fields, [], { mode: 'page' })
375
+ expect(patched).toContain('export const prerender = true')
376
+ expect(patched).not.toMatch(/^export\s*$/m) // no bare "export " line
377
+ })
378
+
379
+ it('should not detect prerender as a content field', async () => {
380
+ const section = await analyzeAstroSection(pageWithPrerender, '_page_test', 'testPage', 'test.astro', { mode: 'page' })
381
+ const keys = section.fields.map(f => f.key)
382
+ expect(keys).not.toContain('prerender')
383
+ })
384
+ })
385
+
386
+ // ---------------------------------------------------------------------------
387
+ // 9. Regression: already-integrated page must not be re-patched
388
+ // Bug: page with getSection/setzkasten:content was offered for adoption,
389
+ // patcher added duplicate imports and removed essential variables.
390
+ // ---------------------------------------------------------------------------
391
+
392
+ describe('Patcher safety: already integrated page not re-patched', () => {
393
+ const integratedPage = `---
394
+ export const prerender = true;
395
+
396
+ import BaseLayout from '../layouts/BaseLayout.astro';
397
+ import HeroSection from '../components/sections/HeroSection.astro';
398
+ import { getPage, getSection } from 'setzkasten:content';
399
+
400
+ const page = getPage('index');
401
+ const normalize = (k: string) => k.replace(/[-_]/g, '').toLowerCase();
402
+ const SECTION_COMPONENTS: Record<string, any> = {
403
+ [normalize('hero')]: HeroSection,
404
+ };
405
+ ---
406
+
407
+ <BaseLayout>
408
+ {page?.sections.map(({ Component, data }) => (
409
+ <Component data={data} />
410
+ ))}
411
+ </BaseLayout>
412
+ `
413
+
414
+ it('should detect alreadyIntegrated and skip adoption', async () => {
415
+ const section = await analyzeAstroSection(integratedPage, '_page_index', 'indexPage', 'test.astro', { mode: 'page' })
416
+ expect(section.alreadyIntegrated).toBe(true)
417
+ })
418
+ })
419
+
420
+ // ---------------------------------------------------------------------------
421
+ // 10. Layout detection: extractLayoutImport
422
+ // ---------------------------------------------------------------------------
423
+
424
+ describe('Layout detection: extractLayoutImport', () => {
425
+ it('should find layout import from page source', () => {
426
+ const pageSource = `---
427
+ import BaseLayout from '../layouts/BaseLayout.astro';
428
+ ---
429
+ <BaseLayout><h1>Hello</h1></BaseLayout>`
430
+
431
+ const repoFiles: RepoFile[] = [
432
+ { path: 'src/pages/index.astro', type: 'blob' },
433
+ { path: 'src/layouts/BaseLayout.astro', type: 'blob' },
434
+ ]
435
+
436
+ const layout = extractLayoutImport(pageSource, 'src/pages/index.astro', repoFiles, '')
437
+ expect(layout).not.toBeNull()
438
+ expect(layout!.componentName).toBe('BaseLayout')
439
+ expect(layout!.resolvedPath).toBe('src/layouts/BaseLayout.astro')
440
+ })
441
+
442
+ it('should return null if no layout import exists', () => {
443
+ const pageSource = `---
444
+ import HeroSection from '../components/sections/HeroSection.astro';
445
+ ---
446
+ <HeroSection />`
447
+
448
+ const repoFiles: RepoFile[] = [
449
+ { path: 'src/pages/index.astro', type: 'blob' },
450
+ { path: 'src/components/sections/HeroSection.astro', type: 'blob' },
451
+ ]
452
+
453
+ const layout = extractLayoutImport(pageSource, 'src/pages/index.astro', repoFiles, '')
454
+ expect(layout).toBeNull()
455
+ })
456
+ })
457
+
458
+ // ---------------------------------------------------------------------------
459
+ // 11. Layout regions: header and footer content detection
460
+ // ---------------------------------------------------------------------------
461
+
462
+ describe('Layout regions: header and footer analysis', () => {
463
+ let layoutSource: string
464
+
465
+ beforeAll(() => {
466
+ layoutSource = readFileSync(join(SECTIONS_DIR, 'layouts', 'TestLayout.astro'), 'utf-8')
467
+ })
468
+
469
+ it('should detect header content as a section', async () => {
470
+ const headerHtml = extractRegionHtml(layoutSource, 'header')
471
+ expect(headerHtml).toBeTruthy()
472
+
473
+ const regionSource = `---\n---\n\n${headerHtml}`
474
+ const section = await analyzeAstroSection(regionSource, '_layout_header', 'header', 'test-layout.astro', { mode: 'page' })
475
+ expect(section.fields.length).toBeGreaterThan(0)
476
+ })
477
+
478
+ it('should detect footer content as a section', async () => {
479
+ const footerHtml = extractRegionHtml(layoutSource, 'footer')
480
+ expect(footerHtml).toBeTruthy()
481
+
482
+ const regionSource = `---\n---\n\n${footerHtml}`
483
+ const section = await analyzeAstroSection(regionSource, '_layout_footer', 'footer', 'test-layout.astro', { mode: 'page' })
484
+ expect(section.fields.length).toBeGreaterThan(0)
485
+ })
486
+
487
+ it('should detect text fields in header (brand name, nav links)', async () => {
488
+ const headerHtml = extractRegionHtml(layoutSource, 'header')!
489
+ const regionSource = `---\n---\n\n${headerHtml}`
490
+ const section = await analyzeAstroSection(regionSource, '_layout_header', 'header', 'test-layout.astro', { mode: 'page' })
491
+
492
+ const textFields = section.fields.filter(f => f.type === 'text')
493
+ expect(textFields.length).toBeGreaterThan(0)
494
+ // Should find brand name or nav link text
495
+ const hasNavContent = textFields.some(
496
+ f => typeof f.defaultValue === 'string' &&
497
+ (f.defaultValue.includes('MeinBrand') || f.defaultValue.includes('Features') || f.defaultValue.includes('Jetzt starten')),
498
+ )
499
+ expect(hasNavContent, 'Should detect header text content').toBe(true)
500
+ })
501
+
502
+ it('should detect text fields in footer (brand, tagline, copyright)', async () => {
503
+ const footerHtml = extractRegionHtml(layoutSource, 'footer')!
504
+ const regionSource = `---\n---\n\n${footerHtml}`
505
+ const section = await analyzeAstroSection(regionSource, '_layout_footer', 'footer', 'test-layout.astro', { mode: 'page' })
506
+
507
+ const textFields = section.fields.filter(f => f.type === 'text')
508
+ expect(textFields.length).toBeGreaterThan(0)
509
+ const hasFooterContent = textFields.some(
510
+ f => typeof f.defaultValue === 'string' &&
511
+ (f.defaultValue.includes('MeinBrand') || f.defaultValue.includes('CMS') || f.defaultValue.includes('Rechte')),
512
+ )
513
+ expect(hasFooterContent, 'Should detect footer text content').toBe(true)
514
+ })
515
+ })
516
+
517
+ // ---------------------------------------------------------------------------
518
+ // 14. Generic page sweep — every file in test-sections/pages/ is analyzed.
519
+ // Automatically covers new test pages without manual registration.
520
+ // Checks: no crash, ≥1 field detected, no [object Object] in any value.
521
+ // ---------------------------------------------------------------------------
522
+
523
+ describe('Page sweep: all pages in pages/', () => {
524
+ const pageFiles = readdirSync(PAGES_DIR).filter(f => f.endsWith('.astro')).sort()
525
+
526
+ for (const file of pageFiles) {
527
+ describe(file, () => {
528
+ let source: string
529
+ let section: Awaited<ReturnType<typeof analyzeAstroSection>>
530
+
531
+ beforeAll(async () => {
532
+ source = readFileSync(join(PAGES_DIR, file), 'utf-8')
533
+ const pageKey = `_page_${basename(file, '.astro').replace(/([A-Z])/g, '_$1').toLowerCase().replace(/^_/, '')}`
534
+ section = await analyzeAstroSection(source, pageKey, 'testPage', `src/pages/${file}`, { mode: 'page' })
535
+ })
536
+
537
+ it('should not crash during analysis', () => {
538
+ expect(section).toBeDefined()
539
+ expect(section.fields).toBeDefined()
540
+ })
541
+
542
+ it('should detect at least one field', () => {
543
+ expect(section.fields.length).toBeGreaterThan(0)
544
+ })
545
+
546
+ it('should not produce [object Object] in any field value', () => {
547
+ // Check for structural [object Object] — array items that weren't parsed as proper
548
+ // objects/primitives but got stringified. String fields that happen to *contain*
549
+ // the text "[object Object]" (e.g. changelog entries describing the bug) are fine.
550
+ expect(hasStructuralObjectString(section.fields)).toBe(false)
551
+ })
552
+ })
553
+ }
554
+ })
555
+
556
+ // ---------------------------------------------------------------------------
557
+ // 15. DocsIndexPage: nested sections array (sections[].items[])
558
+ // Bug case: items array of {title,href,description} objects was shown
559
+ // as [object Object] in the CMS. The analyzer must detect items as
560
+ // an array-of-objects field with correct sub-fields.
561
+ // ---------------------------------------------------------------------------
562
+
563
+ describe('DocsIndexPage: nested sections[].items[] detection', () => {
564
+ let source: string
565
+ let section: Awaited<ReturnType<typeof analyzeAstroSection>>
566
+
567
+ beforeAll(async () => {
568
+ source = readFileSync(join(PAGES_DIR, 'DocsIndexPage.astro'), 'utf-8')
569
+ section = await analyzeAstroSection(
570
+ source, '_page_docs', 'docsPage', 'src/pages/docs/index.astro', { mode: 'page' },
571
+ )
572
+ })
573
+
574
+ it('should detect the outer sections array', () => {
575
+ const keys = section.fields.map(f => f.key)
576
+ expect(keys).toContain('sections')
577
+ })
578
+
579
+ it('should detect sections as an array field', () => {
580
+ const sectionsField = section.fields.find(f => f.key === 'sections')
581
+ expect(sectionsField?.type).toBe('array')
582
+ })
583
+
584
+ it('should detect items inside each section as an array field (not [object Object])', () => {
585
+ const sectionsField = section.fields.find(f => f.key === 'sections')
586
+ const rows = sectionsField?.defaultValue as Array<Record<string, unknown>> | undefined
587
+ expect(Array.isArray(rows)).toBe(true)
588
+ if (rows) {
589
+ for (const row of rows) {
590
+ // items must not stringify to "[object Object]" (would mean objects weren't parsed)
591
+ const itemsValue = row['items']
592
+ expect(JSON.stringify(itemsValue)).not.toContain('[object Object]')
593
+ // items should be an array
594
+ expect(Array.isArray(itemsValue)).toBe(true)
595
+ }
596
+ }
597
+ })
598
+
599
+ it('should detect item sub-fields (title, href, description)', () => {
600
+ const allValues = JSON.stringify(section.fields)
601
+ expect(allValues).not.toContain('[object Object]')
602
+ // At least one item object should have a title key
603
+ const sectionsField = section.fields.find(f => f.key === 'sections')
604
+ const rows = sectionsField?.defaultValue as Array<Record<string, unknown>> | undefined
605
+ if (rows?.[0]) {
606
+ const items = rows[0]['items'] as Array<Record<string, unknown>> | undefined
607
+ expect(items?.[0]).toHaveProperty('title')
608
+ }
609
+ })
610
+ })
611
+
612
+ // ---------------------------------------------------------------------------
613
+ // 16. EventSchedulePage: nested days[].sessions[] map detection
614
+ // Same double-nested map pattern as DocsIndexPage. Each day has
615
+ // sessions with time/title/speaker/track/description.
616
+ // ---------------------------------------------------------------------------
617
+
618
+ describe('EventSchedulePage: nested days[].sessions[] detection', () => {
619
+ let source: string
620
+ let section: Awaited<ReturnType<typeof analyzeAstroSection>>
621
+
622
+ beforeAll(async () => {
623
+ source = readFileSync(join(PAGES_DIR, 'EventSchedulePage.astro'), 'utf-8')
624
+ section = await analyzeAstroSection(
625
+ source, '_page_event_schedule', 'eventPage', 'src/pages/event.astro', { mode: 'page' },
626
+ )
627
+ })
628
+
629
+ it('should detect the outer days array', () => {
630
+ const keys = section.fields.map(f => f.key)
631
+ expect(keys).toContain('days')
632
+ })
633
+
634
+ it('should detect sessions inside each day without [object Object]', () => {
635
+ const daysField = section.fields.find(f => f.key === 'days')
636
+ const rows = daysField?.defaultValue as Array<Record<string, unknown>> | undefined
637
+ expect(Array.isArray(rows)).toBe(true)
638
+ if (rows) {
639
+ for (const row of rows) {
640
+ expect(JSON.stringify(row['sessions'])).not.toContain('[object Object]')
641
+ expect(Array.isArray(row['sessions'])).toBe(true)
642
+ }
643
+ }
644
+ })
645
+
646
+ it('should detect session sub-fields (time, title, speaker)', () => {
647
+ const daysField = section.fields.find(f => f.key === 'days')
648
+ const rows = daysField?.defaultValue as Array<Record<string, unknown>> | undefined
649
+ const sessions = rows?.[0]?.['sessions'] as Array<Record<string, unknown>> | undefined
650
+ expect(sessions?.[0]).toHaveProperty('title')
651
+ expect(sessions?.[0]).toHaveProperty('time')
652
+ })
653
+
654
+ it('should also detect frontmatter scalar vars (eventTitle, eventLocation)', () => {
655
+ const keys = section.fields.map(f => f.key)
656
+ const hasScalars = keys.some(k => k === 'eventTitle' || k === 'eventLocation' || k === 'eventDate')
657
+ expect(hasScalars).toBe(true)
658
+ })
659
+ })
660
+
661
+ // ---------------------------------------------------------------------------
662
+ // 17. ChangelogPage: nested releases[].changes[] detection
663
+ // ---------------------------------------------------------------------------
664
+
665
+ describe('ChangelogPage: nested releases[].changes[] detection', () => {
666
+ let source: string
667
+ let section: Awaited<ReturnType<typeof analyzeAstroSection>>
668
+
669
+ beforeAll(async () => {
670
+ source = readFileSync(join(PAGES_DIR, 'ChangelogPage.astro'), 'utf-8')
671
+ section = await analyzeAstroSection(
672
+ source, '_page_changelog', 'changelogPage', 'src/pages/changelog.astro', { mode: 'page' },
673
+ )
674
+ })
675
+
676
+ it('should detect the releases array', () => {
677
+ const keys = section.fields.map(f => f.key)
678
+ expect(keys).toContain('releases')
679
+ })
680
+
681
+ it('should detect changes inside each release without [object Object]', () => {
682
+ const releasesField = section.fields.find(f => f.key === 'releases')
683
+ const rows = releasesField?.defaultValue as Array<Record<string, unknown>> | undefined
684
+ expect(Array.isArray(rows)).toBe(true)
685
+ if (rows) {
686
+ for (const row of rows) {
687
+ expect(hasStructuralObjectString(row['changes'])).toBe(false)
688
+ expect(Array.isArray(row['changes'])).toBe(true)
689
+ }
690
+ }
691
+ })
692
+
693
+ it('should detect change sub-fields (type, text)', () => {
694
+ const releasesField = section.fields.find(f => f.key === 'releases')
695
+ const rows = releasesField?.defaultValue as Array<Record<string, unknown>> | undefined
696
+ const changes = rows?.[0]?.['changes'] as Array<Record<string, unknown>> | undefined
697
+ expect(changes?.[0]).toHaveProperty('text')
698
+ expect(changes?.[0]).toHaveProperty('type')
699
+ })
700
+ })
701
+
702
+ // ---------------------------------------------------------------------------
703
+ // 18. BlogListPage: frontmatter posts array + single featuredPost object
704
+ // ---------------------------------------------------------------------------
705
+
706
+ describe('BlogListPage: posts array and featuredPost object', () => {
707
+ let source: string
708
+ let section: Awaited<ReturnType<typeof analyzeAstroSection>>
709
+
710
+ beforeAll(async () => {
711
+ source = readFileSync(join(PAGES_DIR, 'BlogListPage.astro'), 'utf-8')
712
+ section = await analyzeAstroSection(
713
+ source, '_page_blog', 'blogPage', 'src/pages/blog/index.astro', { mode: 'page' },
714
+ )
715
+ })
716
+
717
+ it('should detect the posts array', () => {
718
+ const keys = section.fields.map(f => f.key)
719
+ expect(keys).toContain('posts')
720
+ })
721
+
722
+ it('should detect posts as an array-of-objects with expected sub-fields', () => {
723
+ const postsField = section.fields.find(f => f.key === 'posts')
724
+ expect(postsField?.type).toBe('array')
725
+ const items = postsField?.defaultValue as Array<Record<string, unknown>> | undefined
726
+ expect(items?.[0]).toHaveProperty('title')
727
+ expect(items?.[0]).toHaveProperty('excerpt')
728
+ })
729
+
730
+ it('should not contain [object Object] anywhere', () => {
731
+ expect(JSON.stringify(section.fields)).not.toContain('[object Object]')
732
+ })
733
+
734
+ it('should detect featuredPost or its scalar fields', () => {
735
+ const keys = section.fields.map(f => f.key)
736
+ // Either as a single object field or its sub-fields are detected
737
+ const hasFeatured = keys.some(k => k.toLowerCase().includes('featured') || k.toLowerCase().includes('post'))
738
+ expect(hasFeatured).toBe(true)
739
+ })
740
+ })
741
+
742
+ // ---------------------------------------------------------------------------
743
+ // 19. PricingPage: plans array with nested features string array + table
744
+ // ---------------------------------------------------------------------------
745
+
746
+ describe('PricingPage: plans with nested features[] and table', () => {
747
+ let source: string
748
+ let section: Awaited<ReturnType<typeof analyzeAstroSection>>
749
+
750
+ beforeAll(async () => {
751
+ source = readFileSync(join(PAGES_DIR, 'PricingPage.astro'), 'utf-8')
752
+ section = await analyzeAstroSection(
753
+ source, '_page_pricing', 'pricingPage', 'src/pages/pricing.astro', { mode: 'page' },
754
+ )
755
+ })
756
+
757
+ it('should detect the plans array', () => {
758
+ const keys = section.fields.map(f => f.key)
759
+ expect(keys).toContain('plans')
760
+ })
761
+
762
+ it('should not contain [object Object] in plans or nested features', () => {
763
+ const plansField = section.fields.find(f => f.key === 'plans')
764
+ expect(JSON.stringify(plansField)).not.toContain('[object Object]')
765
+ })
766
+
767
+ it('should detect faqItems array', () => {
768
+ const keys = section.fields.map(f => f.key)
769
+ expect(keys).toContain('faqItems')
770
+ })
771
+
772
+ it('should not produce [object Object] anywhere', () => {
773
+ expect(JSON.stringify(section.fields)).not.toContain('[object Object]')
774
+ })
775
+ })
776
+
777
+ // ---------------------------------------------------------------------------
778
+ // 20. LandingIndexPage: inline .map() arrays + frontmatter steps array
779
+ // ---------------------------------------------------------------------------
780
+
781
+ describe('LandingIndexPage: inline maps and steps array', () => {
782
+ let source: string
783
+ let section: Awaited<ReturnType<typeof analyzeAstroSection>>
784
+
785
+ beforeAll(async () => {
786
+ source = readFileSync(join(PAGES_DIR, 'LandingIndexPage.astro'), 'utf-8')
787
+ section = await analyzeAstroSection(
788
+ source, '_page_index', 'indexPage', 'src/pages/index.astro', { mode: 'page' },
789
+ )
790
+ })
791
+
792
+ it('should detect frontmatter scalar vars (headline, ctaText)', () => {
793
+ const keys = section.fields.map(f => f.key)
794
+ const hasHero = keys.some(k => k === 'headline' || k === 'ctaText' || k === 'subheadline')
795
+ expect(hasHero).toBe(true)
796
+ })
797
+
798
+ it('should detect the steps array', () => {
799
+ const keys = section.fields.map(f => f.key)
800
+ expect(keys).toContain('steps')
801
+ })
802
+
803
+ it('should not produce [object Object] anywhere', () => {
804
+ expect(JSON.stringify(section.fields)).not.toContain('[object Object]')
805
+ })
806
+ })
807
+
808
+ // ---------------------------------------------------------------------------
809
+ // 21. AboutPage: team array + milestones array + inline values map
810
+ // ---------------------------------------------------------------------------
811
+
812
+ describe('AboutPage: team, milestones, inline values', () => {
813
+ let source: string
814
+ let section: Awaited<ReturnType<typeof analyzeAstroSection>>
815
+
816
+ beforeAll(async () => {
817
+ source = readFileSync(join(PAGES_DIR, 'AboutPage.astro'), 'utf-8')
818
+ section = await analyzeAstroSection(
819
+ source, '_page_about', 'aboutPage', 'src/pages/about.astro', { mode: 'page' },
820
+ )
821
+ })
822
+
823
+ it('should detect the team array', () => {
824
+ const keys = section.fields.map(f => f.key)
825
+ expect(keys).toContain('team')
826
+ })
827
+
828
+ it('should detect team members as objects with name/role/bio', () => {
829
+ const teamField = section.fields.find(f => f.key === 'team')
830
+ const items = teamField?.defaultValue as Array<Record<string, unknown>> | undefined
831
+ expect(items?.[0]).toHaveProperty('name')
832
+ })
833
+
834
+ it('should detect the milestones array', () => {
835
+ const keys = section.fields.map(f => f.key)
836
+ expect(keys).toContain('milestones')
837
+ })
838
+
839
+ it('should not produce [object Object] anywhere', () => {
840
+ expect(JSON.stringify(section.fields)).not.toContain('[object Object]')
841
+ })
842
+ })
843
+
844
+ /**
845
+ * Helper: extract a <tag>...</tag> region from layout source (strips frontmatter first).
846
+ */
847
+ /**
848
+ * Checks for STRUCTURAL [object Object] — values that were not properly parsed
849
+ * (e.g. an array item got coerced to string "[object Object]").
850
+ * Does NOT flag string fields that happen to CONTAIN "[object Object]" as text.
851
+ */
852
+ function hasStructuralObjectString(value: unknown): boolean {
853
+ if (typeof value === 'string') {
854
+ // A string that IS exactly [object Object] or a comma-joined list of them is structural
855
+ return /^\[object Object\](,\[object Object\])*$/.test(value.trim())
856
+ }
857
+ if (Array.isArray(value)) return value.some(item => hasStructuralObjectString(item))
858
+ if (value !== null && typeof value === 'object') {
859
+ return Object.values(value as object).some(v => hasStructuralObjectString(v))
860
+ }
861
+ return false
862
+ }
863
+
864
+ function extractRegionHtml(layoutSource: string, tag: string): string | null {
865
+ let template = layoutSource
866
+ if (layoutSource.startsWith('---')) {
867
+ const endIdx = layoutSource.indexOf('\n---', 3)
868
+ if (endIdx !== -1) template = layoutSource.slice(endIdx + 4)
869
+ }
870
+
871
+ const openTag = new RegExp(`<${tag}(\\s[^>]*)?>`, 'i')
872
+ const openMatch = openTag.exec(template)
873
+ if (!openMatch) return null
874
+
875
+ const closeTag = `</${tag}>`
876
+ const closeIdx = template.indexOf(closeTag, openMatch.index)
877
+ if (closeIdx === -1) return null
878
+
879
+ return template.slice(openMatch.index, closeIdx + closeTag.length)
880
+ }
881
+
882
+ // ---------------------------------------------------------------------------
883
+ // 12. Docs architecture page: table detection
884
+ // The /docs/architecture page has a <table> with hardcoded <tbody><tr> rows.
885
+ // The analyzer should detect the rows as a repeatedGroup array field.
886
+ // ---------------------------------------------------------------------------
887
+
888
+ describe('Docs architecture page: table row detection', () => {
889
+ let source: string
890
+ let section: Awaited<ReturnType<typeof analyzeAstroSection>>
891
+
892
+ beforeAll(async () => {
893
+ source = readFileSync(join(PAGES_DIR, 'DocsArchitecturePage.astro'), 'utf-8')
894
+ section = await analyzeAstroSection(
895
+ source, '_page_docs_architecture', 'architecturePage',
896
+ 'src/pages/docs/architecture.astro', { mode: 'page' },
897
+ )
898
+ })
899
+
900
+ it('should detect the table as an array field', () => {
901
+ const arrayFields = section.fields.filter(f => f.type === 'array')
902
+ expect(arrayFields.length).toBeGreaterThan(0)
903
+ // Should find an array with 3 entries (the 3 tbody rows)
904
+ const tableField = arrayFields.find(f =>
905
+ Array.isArray(f.defaultValue) && f.defaultValue.length === 3,
906
+ )
907
+ expect(tableField).toBeDefined()
908
+ })
909
+
910
+ it('should produce a repeatedGroup for <tbody><tr>', () => {
911
+ const groups = (section as any)._analyzerResult?.repeatedGroups ?? []
912
+ const tableGroup = groups.find((g: any) => g.tag === 'tr')
913
+ expect(tableGroup).toBeDefined()
914
+ expect(tableGroup.instances).toHaveLength(3)
915
+ })
916
+
917
+ it('should detect td cell content as inner fields', () => {
918
+ const groups = (section as any)._analyzerResult?.repeatedGroups ?? []
919
+ const tableGroup = groups.find((g: any) => g.tag === 'tr')
920
+ expect(tableGroup).toBeDefined()
921
+ // Each row has 3 <td> cells → 3 inner fields
922
+ expect(tableGroup.fields.length).toBeGreaterThanOrEqual(3)
923
+ })
924
+
925
+ it('should label the table field "Tabellen-Zeilen"', () => {
926
+ const tableField = section.fields.find(
927
+ f => f.type === 'array' && Array.isArray(f.defaultValue) && f.defaultValue.length === 3,
928
+ )
929
+ expect(tableField?.label).toBe('Tabellen-Zeilen')
930
+ })
931
+
932
+ it('should include td text in defaultValue', () => {
933
+ const tableField = section.fields.find(
934
+ f => f.type === 'array' && Array.isArray(f.defaultValue) && f.defaultValue.length === 3,
935
+ )
936
+ const rows = tableField?.defaultValue as Array<Record<string, unknown>>
937
+ // First row should contain "ContentRepository"
938
+ const firstRow = rows[0]
939
+ expect(Object.values(firstRow ?? {})).toContain('ContentRepository')
940
+ })
941
+
942
+ it('should also detect headings outside the table', () => {
943
+ const headings = section.fields.filter(f => f.key.startsWith('heading'))
944
+ expect(headings.length).toBeGreaterThan(0)
945
+ })
946
+ })
947
+
948
+ // ---------------------------------------------------------------------------
949
+ // 13. Docs installation page: CodeBlock not treated as a section import
950
+ // CodeBlock is a generic component (not a CMS section).
951
+ // extractSectionImports must NOT pick it up as a section.
952
+ // ---------------------------------------------------------------------------
953
+
954
+ describe('Docs installation page: CodeBlock import handling', () => {
955
+ let source: string
956
+
957
+ beforeAll(() => {
958
+ source = readFileSync(join(PAGES_DIR, 'DocsInstallationPage.astro'), 'utf-8')
959
+ })
960
+
961
+ it('should NOT detect CodeBlock as a section import', () => {
962
+ const imports = extractSectionImports(
963
+ source, 'src/pages/docs/installation.astro', [], '',
964
+ )
965
+ const codeBlockImport = imports.find(i => i.componentName === 'CodeBlock')
966
+ expect(codeBlockImport).toBeUndefined()
967
+ })
968
+
969
+ it('should detect page-level headings on installation page', async () => {
970
+ const section = await analyzeAstroSection(
971
+ source, '_page_docs_installation', 'installationPage',
972
+ 'src/pages/docs/installation.astro', { mode: 'page' },
973
+ )
974
+ const headings = section.fields.filter(f => f.key.startsWith('heading'))
975
+ expect(headings.length).toBeGreaterThan(0)
976
+ })
977
+
978
+ it('should detect repeated CodeBlock components as array in page-level scan', async () => {
979
+ const section = await analyzeAstroSection(
980
+ source, '_page_docs_installation', 'installationPage',
981
+ 'src/pages/docs/installation.astro', { mode: 'page' },
982
+ )
983
+ // 4 CodeBlock components → detected as a "codeBlocks" array
984
+ const codeBlockField = section.fields.find(
985
+ f => f.type === 'array' && typeof f.key === 'string' && f.key.toLowerCase().includes('codeblock'),
986
+ )
987
+ expect(codeBlockField).toBeDefined()
988
+ expect(Array.isArray(codeBlockField?.defaultValue)).toBe(true)
989
+ expect((codeBlockField?.defaultValue as unknown[]).length).toBe(4)
990
+ })
991
+ })
992
+
993
+ // ---------------------------------------------------------------------------
994
+ // 22. Nested map live-edit: inner items must get data-sk-field bindings
995
+ // Bug: patchArrayField patched outer sections.map() but left inner
996
+ // section.items.map() without _j index and without data-sk-field attrs.
997
+ // After fix: inner map must have _j, container binding, and field bindings.
998
+ // ---------------------------------------------------------------------------
999
+
1000
+ describe('Nested map live-edit: inner items have data-sk-field', () => {
1001
+ let source: string
1002
+ let section: Awaited<ReturnType<typeof analyzeAstroSection>>
1003
+ let patched: string
1004
+
1005
+ beforeAll(async () => {
1006
+ source = readFileSync(join(PAGES_DIR, 'DocsIndexPage.astro'), 'utf-8')
1007
+ section = await analyzeAstroSection(
1008
+ source, '_page_docs', 'docsPage', 'src/pages/docs/index.astro', { mode: 'page' },
1009
+ )
1010
+ const groups = (section as any)._analyzerResult?.repeatedGroups ?? []
1011
+ patched = await patchTemplateForFields(source, '_page_docs', section.fields, groups, { mode: 'page' })
1012
+ })
1013
+
1014
+ it('should add _j index to inner .map() call', () => {
1015
+ expect(patched).toMatch(/items\.map\s*\(\s*\(?\s*item\s*,\s*_j\s*\)?/)
1016
+ })
1017
+
1018
+ it('should add container data-sk-field to first element of inner map', () => {
1019
+ expect(patched).toContain('data-sk-field={`_page_docs.sections.${_i}.items.${_j}`}')
1020
+ })
1021
+
1022
+ it('should add data-sk-field for item.title in inner map', () => {
1023
+ expect(patched).toContain('_page_docs.sections.${_i}.items.${_j}.title')
1024
+ })
1025
+
1026
+ it('should add data-sk-field for item.description in inner map', () => {
1027
+ expect(patched).toContain('_page_docs.sections.${_i}.items.${_j}.description')
1028
+ })
1029
+
1030
+ it('should still have outer sections map with _i index', () => {
1031
+ expect(patched).toMatch(/sections.*map.*section.*_i/)
1032
+ })
1033
+ })