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