@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.
- package/LICENSE +37 -0
- package/package.json +70 -0
- package/src/admin-page.astro +148 -0
- package/src/api-routes/__tests__/add-section-helpers.test.ts +383 -0
- package/src/api-routes/__tests__/catalog-api.test.ts +115 -0
- package/src/api-routes/__tests__/deferred-operations.test.ts +232 -0
- package/src/api-routes/__tests__/deploy-hook.test.ts +134 -0
- package/src/api-routes/__tests__/patch-page-file.test.ts +193 -0
- package/src/api-routes/__tests__/scan-page-helpers.test.ts +162 -0
- package/src/api-routes/__tests__/section-management.test.ts +284 -0
- package/src/api-routes/_storage-config.ts +54 -0
- package/src/api-routes/asset-proxy.ts +76 -0
- package/src/api-routes/auth-callback.ts +105 -0
- package/src/api-routes/auth-login.ts +87 -0
- package/src/api-routes/auth-logout.ts +9 -0
- package/src/api-routes/auth-session.ts +36 -0
- package/src/api-routes/catalog-add.ts +151 -0
- package/src/api-routes/catalog-export.ts +86 -0
- package/src/api-routes/catalog-helpers.ts +83 -0
- package/src/api-routes/catalog-list.ts +12 -0
- package/src/api-routes/config.ts +30 -0
- package/src/api-routes/deploy-hook.ts +69 -0
- package/src/api-routes/github-proxy.ts +111 -0
- package/src/api-routes/init-add-section.ts +511 -0
- package/src/api-routes/init-apply.ts +270 -0
- package/src/api-routes/init-migrate.ts +262 -0
- package/src/api-routes/init-scan-page.ts +336 -0
- package/src/api-routes/init-scan.ts +162 -0
- package/src/api-routes/pages.ts +17 -0
- package/src/api-routes/section-add.ts +189 -0
- package/src/api-routes/section-commit-pending.ts +147 -0
- package/src/api-routes/section-delete.ts +141 -0
- package/src/api-routes/section-duplicate.ts +144 -0
- package/src/api-routes/section-management.ts +95 -0
- package/src/api-routes/section-prepare-copy.ts +93 -0
- package/src/api-routes/section-prepare.ts +121 -0
- package/src/env.d.ts +7 -0
- package/src/init/__tests__/page-level.test.ts +1033 -0
- package/src/init/__tests__/page-list-coverage.test.ts +474 -0
- package/src/init/__tests__/patcher-edge-cases.test.ts +434 -0
- package/src/init/__tests__/patcher-page-mode.test.ts +272 -0
- package/src/init/__tests__/section-pipeline.test.ts +393 -0
- package/src/init/analyzer-types.ts +92 -0
- package/src/init/astro-config-patcher.ts +98 -0
- package/src/init/astro-detector.ts +207 -0
- package/src/init/astro-section-analyzer-v2.ts +1663 -0
- package/src/init/field-label-enricher.ts +72 -0
- package/src/init/template-patcher-v2.ts +1957 -0
- 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
|
+
})
|