@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,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the pure helper logic in init-scan-page.ts
|
|
3
|
+
*
|
|
4
|
+
* The route handler itself requires GitHub API + session auth. We test
|
|
5
|
+
* the extractable pure functions directly:
|
|
6
|
+
*
|
|
7
|
+
* 1. pageKey normalisation (pagePath → sectionKey)
|
|
8
|
+
* The `_page_` prefix and `/` → `_` substitution must be correct,
|
|
9
|
+
* because the sectionKey is used to look up content JSON files.
|
|
10
|
+
* Wrong key = 404 in content API.
|
|
11
|
+
*
|
|
12
|
+
* 2. extractLayoutRegions()
|
|
13
|
+
* Extracts <header> and <footer> from a layout file. Used to
|
|
14
|
+
* surface global nav/footer as editable sections in the admin.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { describe, it, expect } from 'vitest'
|
|
18
|
+
import { extractLayoutRegions } from '../init-scan-page'
|
|
19
|
+
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
// 1. pageKey normalisation — re-implemented inline from the route handler
|
|
22
|
+
// so we can test the exact logic without importing APIRoute deps.
|
|
23
|
+
// IMPORTANT: keep in sync with init-scan-page.ts lines 172-176.
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
|
|
26
|
+
function pagePathToSectionKey(pagePath: string): string {
|
|
27
|
+
const pageKeyNorm = pagePath
|
|
28
|
+
.replace(/^src\/pages\//, '')
|
|
29
|
+
.replace(/\/(index)?\.astro$/, '')
|
|
30
|
+
.replace(/\.astro$/, '') || 'index'
|
|
31
|
+
return '_page_' + pageKeyNorm.replace(/\//g, '_')
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
describe('pagePathToSectionKey — correct key derivation', () => {
|
|
35
|
+
it('index page', () => {
|
|
36
|
+
expect(pagePathToSectionKey('src/pages/index.astro')).toBe('_page_index')
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('top-level page', () => {
|
|
40
|
+
expect(pagePathToSectionKey('src/pages/about.astro')).toBe('_page_about')
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('nested page', () => {
|
|
44
|
+
expect(pagePathToSectionKey('src/pages/docs/architecture.astro')).toBe('_page_docs_architecture')
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('uses underscore not double-dash for nested', () => {
|
|
48
|
+
expect(pagePathToSectionKey('src/pages/docs/architecture.astro')).not.toContain('--')
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('nested index page', () => {
|
|
52
|
+
// src/pages/docs/index.astro → pageKey should be 'docs'
|
|
53
|
+
expect(pagePathToSectionKey('src/pages/docs/index.astro')).toBe('_page_docs')
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('three levels deep', () => {
|
|
57
|
+
expect(pagePathToSectionKey('src/pages/blog/2024/intro.astro')).toBe('_page_blog_2024_intro')
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('fields page (real regression case)', () => {
|
|
61
|
+
// This was the page that broke — docs/fields.astro
|
|
62
|
+
expect(pagePathToSectionKey('src/pages/docs/fields.astro')).toBe('_page_docs_fields')
|
|
63
|
+
})
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
// ---------------------------------------------------------------------------
|
|
67
|
+
// 2. extractLayoutRegions
|
|
68
|
+
// ---------------------------------------------------------------------------
|
|
69
|
+
|
|
70
|
+
describe('extractLayoutRegions — header and footer extraction', () => {
|
|
71
|
+
const layoutWithBoth = `---
|
|
72
|
+
import { getSection } from 'setzkasten:content'
|
|
73
|
+
---
|
|
74
|
+
|
|
75
|
+
<html>
|
|
76
|
+
<body>
|
|
77
|
+
<header class="sticky top-0 z-50">
|
|
78
|
+
<nav>
|
|
79
|
+
<a href="/">Home</a>
|
|
80
|
+
<a href="/about">About</a>
|
|
81
|
+
</nav>
|
|
82
|
+
</header>
|
|
83
|
+
|
|
84
|
+
<main>
|
|
85
|
+
<slot />
|
|
86
|
+
</main>
|
|
87
|
+
|
|
88
|
+
<footer class="py-8">
|
|
89
|
+
<p>© 2024 Lilapixel</p>
|
|
90
|
+
<a href="/impressum">Impressum</a>
|
|
91
|
+
</footer>
|
|
92
|
+
</body>
|
|
93
|
+
</html>
|
|
94
|
+
`
|
|
95
|
+
|
|
96
|
+
it('should extract header region', () => {
|
|
97
|
+
const regions = extractLayoutRegions(layoutWithBoth)
|
|
98
|
+
expect(regions.some(r => r.name === 'header')).toBe(true)
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
it('should extract footer region', () => {
|
|
102
|
+
const regions = extractLayoutRegions(layoutWithBoth)
|
|
103
|
+
expect(regions.some(r => r.name === 'footer')).toBe(true)
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it('header region HTML should contain the nav content', () => {
|
|
107
|
+
const regions = extractLayoutRegions(layoutWithBoth)
|
|
108
|
+
const header = regions.find(r => r.name === 'header')!
|
|
109
|
+
expect(header.html).toContain('<nav>')
|
|
110
|
+
expect(header.html).toContain('Home')
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
it('footer region HTML should contain the footer content', () => {
|
|
114
|
+
const regions = extractLayoutRegions(layoutWithBoth)
|
|
115
|
+
const footer = regions.find(r => r.name === 'footer')!
|
|
116
|
+
expect(footer.html).toContain('Lilapixel')
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
it('should set correct tag names', () => {
|
|
120
|
+
const regions = extractLayoutRegions(layoutWithBoth)
|
|
121
|
+
for (const r of regions) {
|
|
122
|
+
expect(r.tag).toBe(r.name)
|
|
123
|
+
}
|
|
124
|
+
})
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
describe('extractLayoutRegions — partial layouts', () => {
|
|
128
|
+
it('returns only header if no footer', () => {
|
|
129
|
+
const source = `---\n---\n<html><header><nav>Menu</nav></header><main><slot /></main></html>`
|
|
130
|
+
const regions = extractLayoutRegions(source)
|
|
131
|
+
expect(regions).toHaveLength(1)
|
|
132
|
+
expect(regions[0]!.name).toBe('header')
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
it('returns only footer if no header', () => {
|
|
136
|
+
const source = `---\n---\n<html><main><slot /></main><footer><p>Footer</p></footer></html>`
|
|
137
|
+
const regions = extractLayoutRegions(source)
|
|
138
|
+
expect(regions).toHaveLength(1)
|
|
139
|
+
expect(regions[0]!.name).toBe('footer')
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
it('returns empty array if neither header nor footer', () => {
|
|
143
|
+
const source = `---\n---\n<html><main><slot /></main></html>`
|
|
144
|
+
const regions = extractLayoutRegions(source)
|
|
145
|
+
expect(regions).toHaveLength(0)
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
it('handles layout without frontmatter', () => {
|
|
149
|
+
const source = `<html><header><a href="/">Home</a></header></html>`
|
|
150
|
+
const regions = extractLayoutRegions(source)
|
|
151
|
+
expect(regions).toHaveLength(1)
|
|
152
|
+
})
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
describe('extractLayoutRegions — attributes on tags', () => {
|
|
156
|
+
it('handles header with class attributes', () => {
|
|
157
|
+
const source = `---\n---\n<header class="sticky top-0 bg-white z-50"><nav>nav</nav></header>`
|
|
158
|
+
const regions = extractLayoutRegions(source)
|
|
159
|
+
expect(regions).toHaveLength(1)
|
|
160
|
+
expect(regions[0]!.html).toContain('sticky top-0')
|
|
161
|
+
})
|
|
162
|
+
})
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for section-management helpers: delete and duplicate.
|
|
3
|
+
*
|
|
4
|
+
* Pure logic — no GitHub API involved.
|
|
5
|
+
* Covered:
|
|
6
|
+
* 1. removeFromPageConfig: removes a section entry from page config
|
|
7
|
+
* 2. generateDuplicateKey: unique key generation with suffix
|
|
8
|
+
* 3. duplicateInPageConfig: adds duplicate entry after the original
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { describe, it, expect } from 'vitest'
|
|
12
|
+
import {
|
|
13
|
+
removeFromPageConfig,
|
|
14
|
+
generateDuplicateKey,
|
|
15
|
+
duplicateInPageConfig,
|
|
16
|
+
generateAddKey,
|
|
17
|
+
addToPageConfig,
|
|
18
|
+
} from '../section-management'
|
|
19
|
+
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
// Fixtures
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
|
|
24
|
+
const pageConfig = {
|
|
25
|
+
sections: [
|
|
26
|
+
{ key: 'hero', enabled: true, order: 0 },
|
|
27
|
+
{ key: 'features', enabled: true, order: 1 },
|
|
28
|
+
{ key: 'cta', enabled: false, order: 2 },
|
|
29
|
+
],
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
// removeFromPageConfig
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
|
|
36
|
+
describe('removeFromPageConfig', () => {
|
|
37
|
+
it('removes the matching section entry', () => {
|
|
38
|
+
const result = removeFromPageConfig(pageConfig, 'features')
|
|
39
|
+
expect(result.sections.map(s => s.key)).toEqual(['hero', 'cta'])
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('re-numbers order after removal', () => {
|
|
43
|
+
const result = removeFromPageConfig(pageConfig, 'features')
|
|
44
|
+
expect(result.sections[0]!.order).toBe(0)
|
|
45
|
+
expect(result.sections[1]!.order).toBe(1)
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('does not mutate the original config', () => {
|
|
49
|
+
removeFromPageConfig(pageConfig, 'hero')
|
|
50
|
+
expect(pageConfig.sections).toHaveLength(3)
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('returns config unchanged if key does not exist', () => {
|
|
54
|
+
const result = removeFromPageConfig(pageConfig, 'nonexistent')
|
|
55
|
+
expect(result.sections).toHaveLength(3)
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('works when removing the only section', () => {
|
|
59
|
+
const single = { sections: [{ key: 'hero', enabled: true, order: 0 }] }
|
|
60
|
+
const result = removeFromPageConfig(single, 'hero')
|
|
61
|
+
expect(result.sections).toHaveLength(0)
|
|
62
|
+
})
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
// ---------------------------------------------------------------------------
|
|
66
|
+
// generateDuplicateKey
|
|
67
|
+
// ---------------------------------------------------------------------------
|
|
68
|
+
|
|
69
|
+
describe('generateDuplicateKey', () => {
|
|
70
|
+
it('appends --copy when key is free', () => {
|
|
71
|
+
expect(generateDuplicateKey(['hero', 'features'], 'hero')).toBe('hero--copy')
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('appends --copy2 when hero--copy already exists', () => {
|
|
75
|
+
expect(generateDuplicateKey(['hero', 'hero--copy'], 'hero')).toBe('hero--copy2')
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it('appends --copy3 when hero--copy and hero--copy2 exist', () => {
|
|
79
|
+
expect(generateDuplicateKey(['hero', 'hero--copy', 'hero--copy2'], 'hero')).toBe('hero--copy3')
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it('works for multi-instance keys (hero--about → hero--about--copy)', () => {
|
|
83
|
+
expect(generateDuplicateKey(['hero--about'], 'hero--about')).toBe('hero--about--copy')
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it('does not conflict with unrelated keys containing copy', () => {
|
|
87
|
+
expect(generateDuplicateKey(['features', 'features--copycat'], 'features')).toBe('features--copy')
|
|
88
|
+
})
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
// ---------------------------------------------------------------------------
|
|
92
|
+
// duplicateInPageConfig
|
|
93
|
+
// ---------------------------------------------------------------------------
|
|
94
|
+
|
|
95
|
+
describe('duplicateInPageConfig', () => {
|
|
96
|
+
it('inserts duplicate entry immediately after the original', () => {
|
|
97
|
+
const result = duplicateInPageConfig(pageConfig, 'hero', 'hero--copy')
|
|
98
|
+
const keys = result.sections.map(s => s.key)
|
|
99
|
+
expect(keys).toEqual(['hero', 'hero--copy', 'features', 'cta'])
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
it('new entry is enabled regardless of original enabled state', () => {
|
|
103
|
+
const result = duplicateInPageConfig(pageConfig, 'cta', 'cta--copy')
|
|
104
|
+
const copy = result.sections.find(s => s.key === 'cta--copy')!
|
|
105
|
+
expect(copy.enabled).toBe(true)
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
it('re-numbers order after insertion', () => {
|
|
109
|
+
const result = duplicateInPageConfig(pageConfig, 'hero', 'hero--copy')
|
|
110
|
+
result.sections.forEach((s, i) => expect(s.order).toBe(i))
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
it('preserves type field from original if present', () => {
|
|
114
|
+
const withType = {
|
|
115
|
+
sections: [
|
|
116
|
+
{ key: 'hero--about', type: 'hero', enabled: true, order: 0 },
|
|
117
|
+
],
|
|
118
|
+
}
|
|
119
|
+
const result = duplicateInPageConfig(withType, 'hero--about', 'hero--about--copy')
|
|
120
|
+
const copy = result.sections.find(s => s.key === 'hero--about--copy')!
|
|
121
|
+
expect((copy as any).type).toBe('hero')
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
it('does not mutate the original config', () => {
|
|
125
|
+
duplicateInPageConfig(pageConfig, 'hero', 'hero--copy')
|
|
126
|
+
expect(pageConfig.sections).toHaveLength(3)
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
it('returns config unchanged if original key does not exist', () => {
|
|
130
|
+
const result = duplicateInPageConfig(pageConfig, 'nonexistent', 'nonexistent--copy')
|
|
131
|
+
expect(result.sections).toHaveLength(3)
|
|
132
|
+
})
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
// ---------------------------------------------------------------------------
|
|
136
|
+
// generateAddKey
|
|
137
|
+
// ---------------------------------------------------------------------------
|
|
138
|
+
|
|
139
|
+
describe('generateAddKey', () => {
|
|
140
|
+
it('returns type as key when no existing keys', () => {
|
|
141
|
+
expect(generateAddKey([], 'hero')).toBe('hero')
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
it('returns type as key when type key is not taken', () => {
|
|
145
|
+
expect(generateAddKey(['features', 'cta'], 'hero')).toBe('hero')
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
it('appends --2 when base key is taken', () => {
|
|
149
|
+
expect(generateAddKey(['hero'], 'hero')).toBe('hero--2')
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
it('appends --3 when hero and hero--2 are taken', () => {
|
|
153
|
+
expect(generateAddKey(['hero', 'hero--2'], 'hero')).toBe('hero--3')
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
it('does not conflict with unrelated keys', () => {
|
|
157
|
+
expect(generateAddKey(['hero-section', 'hero--about'], 'hero')).toBe('hero')
|
|
158
|
+
})
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
// ---------------------------------------------------------------------------
|
|
162
|
+
// delete workflow integration (helpers combined)
|
|
163
|
+
// ---------------------------------------------------------------------------
|
|
164
|
+
|
|
165
|
+
describe('delete workflow', () => {
|
|
166
|
+
it('removes section and renumbers order', () => {
|
|
167
|
+
const after = removeFromPageConfig(pageConfig, 'features')
|
|
168
|
+
expect(after.sections.map(s => s.key)).toEqual(['hero', 'cta'])
|
|
169
|
+
expect(after.sections[0]!.order).toBe(0)
|
|
170
|
+
expect(after.sections[1]!.order).toBe(1)
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
it('removing the last section leaves empty array', () => {
|
|
174
|
+
let cfg = removeFromPageConfig(pageConfig, 'hero')
|
|
175
|
+
cfg = removeFromPageConfig(cfg, 'features')
|
|
176
|
+
cfg = removeFromPageConfig(cfg, 'cta')
|
|
177
|
+
expect(cfg.sections).toHaveLength(0)
|
|
178
|
+
})
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
// ---------------------------------------------------------------------------
|
|
182
|
+
// duplicate workflow integration (helpers combined)
|
|
183
|
+
// ---------------------------------------------------------------------------
|
|
184
|
+
|
|
185
|
+
describe('duplicate workflow', () => {
|
|
186
|
+
it('generates key and inserts copy right after original', () => {
|
|
187
|
+
const existingKeys = pageConfig.sections.map(s => s.key)
|
|
188
|
+
const newKey = generateDuplicateKey(existingKeys, 'hero')
|
|
189
|
+
const after = duplicateInPageConfig(pageConfig, 'hero', newKey)
|
|
190
|
+
expect(newKey).toBe('hero--copy')
|
|
191
|
+
expect(after.sections.map(s => s.key)).toEqual(['hero', 'hero--copy', 'features', 'cta'])
|
|
192
|
+
after.sections.forEach((s, i) => expect(s.order).toBe(i))
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
it('handles second duplicate when --copy already exists', () => {
|
|
196
|
+
const withCopy = {
|
|
197
|
+
sections: [
|
|
198
|
+
{ key: 'hero', enabled: true, order: 0 },
|
|
199
|
+
{ key: 'hero--copy', enabled: true, order: 1 },
|
|
200
|
+
],
|
|
201
|
+
}
|
|
202
|
+
const existingKeys = withCopy.sections.map(s => s.key)
|
|
203
|
+
const newKey = generateDuplicateKey(existingKeys, 'hero')
|
|
204
|
+
expect(newKey).toBe('hero--copy2')
|
|
205
|
+
const after = duplicateInPageConfig(withCopy, 'hero', newKey)
|
|
206
|
+
expect(after.sections.map(s => s.key)).toEqual(['hero', 'hero--copy2', 'hero--copy'])
|
|
207
|
+
})
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
// ---------------------------------------------------------------------------
|
|
211
|
+
// addToPageConfig
|
|
212
|
+
// ---------------------------------------------------------------------------
|
|
213
|
+
|
|
214
|
+
describe('addToPageConfig', () => {
|
|
215
|
+
it('appends new entry at the end', () => {
|
|
216
|
+
const result = addToPageConfig(pageConfig, 'testimonials', 'testimonials')
|
|
217
|
+
expect(result.sections.map(s => s.key)).toEqual(['hero', 'features', 'cta', 'testimonials'])
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
it('new entry is enabled by default', () => {
|
|
221
|
+
const result = addToPageConfig(pageConfig, 'testimonials', 'testimonials')
|
|
222
|
+
const entry = result.sections.find(s => s.key === 'testimonials')!
|
|
223
|
+
expect(entry.enabled).toBe(true)
|
|
224
|
+
})
|
|
225
|
+
|
|
226
|
+
it('sets type field when key differs from type', () => {
|
|
227
|
+
const result = addToPageConfig(pageConfig, 'hero--2', 'hero')
|
|
228
|
+
const entry = result.sections.find(s => s.key === 'hero--2')!
|
|
229
|
+
expect((entry as any).type).toBe('hero')
|
|
230
|
+
})
|
|
231
|
+
|
|
232
|
+
it('omits type field when key equals type', () => {
|
|
233
|
+
const result = addToPageConfig(pageConfig, 'testimonials', 'testimonials')
|
|
234
|
+
const entry = result.sections.find(s => s.key === 'testimonials')!
|
|
235
|
+
expect((entry as any).type).toBeUndefined()
|
|
236
|
+
})
|
|
237
|
+
|
|
238
|
+
it('assigns correct order (after last existing)', () => {
|
|
239
|
+
const result = addToPageConfig(pageConfig, 'testimonials', 'testimonials')
|
|
240
|
+
const entry = result.sections.find(s => s.key === 'testimonials')!
|
|
241
|
+
expect(entry.order).toBe(3)
|
|
242
|
+
})
|
|
243
|
+
|
|
244
|
+
it('does not mutate the original config', () => {
|
|
245
|
+
addToPageConfig(pageConfig, 'testimonials', 'testimonials')
|
|
246
|
+
expect(pageConfig.sections).toHaveLength(3)
|
|
247
|
+
})
|
|
248
|
+
})
|
|
249
|
+
|
|
250
|
+
// ---------------------------------------------------------------------------
|
|
251
|
+
// section-prepare: contract tests (key generation + config update, no commit)
|
|
252
|
+
// These test the same helpers the prepare endpoint uses, documenting the
|
|
253
|
+
// expected behavior of optimistic/deferred section addition.
|
|
254
|
+
// ---------------------------------------------------------------------------
|
|
255
|
+
|
|
256
|
+
describe('section-prepare contract', () => {
|
|
257
|
+
it('generates key and returns updated config without touching GitHub', () => {
|
|
258
|
+
const existingKeys = pageConfig.sections.map(s => s.key)
|
|
259
|
+
const newKey = generateAddKey(existingKeys, 'features') // 'features' is taken
|
|
260
|
+
expect(newKey).toBe('features--2')
|
|
261
|
+
const updated = addToPageConfig(pageConfig, newKey, 'features')
|
|
262
|
+
expect(updated.sections).toHaveLength(4)
|
|
263
|
+
const entry = updated.sections.find(s => s.key === 'features--2')!
|
|
264
|
+
expect((entry as any).type).toBe('features')
|
|
265
|
+
expect(entry.enabled).toBe(true)
|
|
266
|
+
})
|
|
267
|
+
|
|
268
|
+
it('new section key is unique even after two pending adds of same type', () => {
|
|
269
|
+
let keys = pageConfig.sections.map(s => s.key)
|
|
270
|
+
const key1 = generateAddKey(keys, 'hero') // hero is taken → hero--2
|
|
271
|
+
expect(key1).toBe('hero--2')
|
|
272
|
+
keys = [...keys, key1]
|
|
273
|
+
const key2 = generateAddKey(keys, 'hero') // hero + hero--2 taken → hero--3
|
|
274
|
+
expect(key2).toBe('hero--3')
|
|
275
|
+
})
|
|
276
|
+
|
|
277
|
+
it('addToPageConfig preserves existing order entries', () => {
|
|
278
|
+
const updated = addToPageConfig(pageConfig, 'newSection', 'newSection')
|
|
279
|
+
expect(updated.sections[0]!.order).toBe(0)
|
|
280
|
+
expect(updated.sections[1]!.order).toBe(1)
|
|
281
|
+
expect(updated.sections[2]!.order).toBe(2)
|
|
282
|
+
expect(updated.sections[3]!.order).toBe(3) // new section gets next order
|
|
283
|
+
})
|
|
284
|
+
})
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolve storage config from all available sources:
|
|
3
|
+
* 1. Request body (explicit override)
|
|
4
|
+
* 2. __SETZKASTEN_STORAGE__ (Vite define, embedded at build time — works everywhere)
|
|
5
|
+
* 3. globalThis.__SETZKASTEN_CONFIG__ (injectScript, only works for rendered pages)
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
declare const __SETZKASTEN_STORAGE__: {
|
|
9
|
+
owner: string
|
|
10
|
+
repo: string
|
|
11
|
+
branch: string
|
|
12
|
+
contentPath: string
|
|
13
|
+
assetsPath: string
|
|
14
|
+
/** Monorepo prefix, e.g. 'apps/website'. Empty string for standalone projects. */
|
|
15
|
+
projectPrefix: string
|
|
16
|
+
} | null
|
|
17
|
+
|
|
18
|
+
export interface StorageConfig {
|
|
19
|
+
owner: string
|
|
20
|
+
repo: string
|
|
21
|
+
branch: string
|
|
22
|
+
/** Monorepo prefix to prepend to file paths, e.g. 'apps/website' */
|
|
23
|
+
projectPrefix: string
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function resolveStorageConfig(body?: {
|
|
27
|
+
owner?: string
|
|
28
|
+
repo?: string
|
|
29
|
+
branch?: string
|
|
30
|
+
}): StorageConfig | null {
|
|
31
|
+
// Build-time constant (Vite define) — available in all modules including API routes
|
|
32
|
+
const buildConfig = typeof __SETZKASTEN_STORAGE__ !== 'undefined' ? __SETZKASTEN_STORAGE__ : null
|
|
33
|
+
|
|
34
|
+
// Runtime fallback (injectScript page-ssr — only for rendered pages)
|
|
35
|
+
const serverConfig = (globalThis as any).__SETZKASTEN_CONFIG__
|
|
36
|
+
|
|
37
|
+
const owner = body?.owner || buildConfig?.owner || serverConfig?.storage?.owner
|
|
38
|
+
const repo = body?.repo || buildConfig?.repo || serverConfig?.storage?.repo
|
|
39
|
+
const branch = body?.branch || buildConfig?.branch || serverConfig?.storage?.branch || 'main'
|
|
40
|
+
const projectPrefix = buildConfig?.projectPrefix ?? ''
|
|
41
|
+
|
|
42
|
+
if (!owner || !repo) return null
|
|
43
|
+
|
|
44
|
+
return { owner, repo, branch, projectPrefix }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Prefix a file path with the monorepo project prefix.
|
|
49
|
+
* e.g. prefixPath('src/pages/index.astro', 'apps/website') → 'apps/website/src/pages/index.astro'
|
|
50
|
+
*/
|
|
51
|
+
export function prefixPath(filePath: string, projectPrefix: string): string {
|
|
52
|
+
if (!projectPrefix) return filePath
|
|
53
|
+
return `${projectPrefix}/${filePath}`
|
|
54
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import type { APIRoute } from 'astro'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Asset proxy – serves images from the private GitHub repo.
|
|
5
|
+
* Used by the admin UI to display image thumbnails without exposing the GitHub token.
|
|
6
|
+
*
|
|
7
|
+
* GET /api/setzkasten/asset/public/images/about/LP_Logo.png
|
|
8
|
+
* → fetches from GitHub API and returns the raw binary with correct Content-Type.
|
|
9
|
+
*/
|
|
10
|
+
export const GET: APIRoute = async ({ params, cookies }) => {
|
|
11
|
+
const session = cookies.get('setzkasten_session')?.value
|
|
12
|
+
if (!session) {
|
|
13
|
+
return new Response('Unauthorized', { status: 401 })
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const assetPath = params.path
|
|
17
|
+
if (!assetPath) {
|
|
18
|
+
return new Response('Missing path', { status: 400 })
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const githubToken = import.meta.env.GITHUB_TOKEN ?? process.env.GITHUB_TOKEN
|
|
22
|
+
if (!githubToken) {
|
|
23
|
+
return new Response('GitHub token not configured', { status: 500 })
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const config = (globalThis as any).__SETZKASTEN_CONFIG__
|
|
27
|
+
if (!config?.storage) {
|
|
28
|
+
return new Response('Storage not configured', { status: 500 })
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const { owner, repo, branch } = config.storage
|
|
32
|
+
const githubUrl = `https://api.github.com/repos/${owner}/${repo}/contents/${assetPath}?ref=${branch}`
|
|
33
|
+
|
|
34
|
+
try {
|
|
35
|
+
const response = await fetch(githubUrl, {
|
|
36
|
+
headers: {
|
|
37
|
+
Authorization: `Bearer ${githubToken}`,
|
|
38
|
+
Accept: 'application/vnd.github.raw+json',
|
|
39
|
+
'X-GitHub-Api-Version': '2022-11-28',
|
|
40
|
+
},
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
if (!response.ok) {
|
|
44
|
+
return new Response('Asset not found', { status: response.status })
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const contentType = guessMimeType(assetPath)
|
|
48
|
+
const body = await response.arrayBuffer()
|
|
49
|
+
|
|
50
|
+
return new Response(body, {
|
|
51
|
+
status: 200,
|
|
52
|
+
headers: {
|
|
53
|
+
'Content-Type': contentType,
|
|
54
|
+
'Cache-Control': 'private, max-age=300',
|
|
55
|
+
},
|
|
56
|
+
})
|
|
57
|
+
} catch (error) {
|
|
58
|
+
console.error('[setzkasten] Asset proxy error:', error)
|
|
59
|
+
return new Response('Failed to fetch asset', { status: 502 })
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function guessMimeType(path: string): string {
|
|
64
|
+
const ext = path.split('.').pop()?.toLowerCase()
|
|
65
|
+
const types: Record<string, string> = {
|
|
66
|
+
jpg: 'image/jpeg',
|
|
67
|
+
jpeg: 'image/jpeg',
|
|
68
|
+
png: 'image/png',
|
|
69
|
+
gif: 'image/gif',
|
|
70
|
+
webp: 'image/webp',
|
|
71
|
+
avif: 'image/avif',
|
|
72
|
+
svg: 'image/svg+xml',
|
|
73
|
+
pdf: 'application/pdf',
|
|
74
|
+
}
|
|
75
|
+
return types[ext ?? ''] ?? 'application/octet-stream'
|
|
76
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import type { APIRoute } from 'astro'
|
|
2
|
+
import { createGitHubAuth } from '@setzkasten-cms/auth'
|
|
3
|
+
import { createGoogleAuth } from '@setzkasten-cms/auth'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* OAuth callback handler.
|
|
7
|
+
* Receives the authorization code from GitHub/Google, exchanges it for a
|
|
8
|
+
* session via the auth adapter, and sets a signed session cookie.
|
|
9
|
+
*/
|
|
10
|
+
export const GET: APIRoute = async ({ request, url, cookies, redirect }) => {
|
|
11
|
+
const code = url.searchParams.get('code')
|
|
12
|
+
const state = url.searchParams.get('state')
|
|
13
|
+
|
|
14
|
+
if (!code) {
|
|
15
|
+
return new Response('Missing authorization code', { status: 400 })
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Parse stored state (may contain provider info)
|
|
19
|
+
const storedRaw = cookies.get('setzkasten_oauth_state')?.value
|
|
20
|
+
let storedState: string | undefined
|
|
21
|
+
let provider = 'github'
|
|
22
|
+
if (storedRaw) {
|
|
23
|
+
try {
|
|
24
|
+
const parsed = JSON.parse(storedRaw)
|
|
25
|
+
storedState = parsed.state
|
|
26
|
+
provider = parsed.provider ?? 'github'
|
|
27
|
+
} catch {
|
|
28
|
+
storedState = storedRaw
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// CSRF: verify state matches stored state
|
|
33
|
+
if (state && storedState && state !== storedState) {
|
|
34
|
+
return new Response('Invalid state parameter', { status: 400 })
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Clear the state cookie
|
|
38
|
+
cookies.delete('setzkasten_oauth_state', { path: '/' })
|
|
39
|
+
|
|
40
|
+
const config = (globalThis as Record<string, unknown>).__SETZKASTEN_CONFIG__ as
|
|
41
|
+
| { adminPath: string }
|
|
42
|
+
| undefined
|
|
43
|
+
|
|
44
|
+
const adminPath = config?.adminPath ?? '/admin'
|
|
45
|
+
|
|
46
|
+
// On Vercel, url.origin may resolve to localhost. Use the Host header instead.
|
|
47
|
+
const host = request.headers.get('x-forwarded-host') ?? request.headers.get('host') ?? url.host
|
|
48
|
+
const protocol = request.headers.get('x-forwarded-proto') ?? (url.protocol === 'https:' ? 'https' : 'http')
|
|
49
|
+
const origin = `${protocol}://${host}`
|
|
50
|
+
const redirectUri = `${origin}/api/setzkasten/auth/callback`
|
|
51
|
+
|
|
52
|
+
// Read allowed emails from env (comma-separated)
|
|
53
|
+
const allowedEmailsRaw = import.meta.env.SETZKASTEN_ALLOWED_EMAILS ?? process.env.SETZKASTEN_ALLOWED_EMAILS ?? ''
|
|
54
|
+
const allowedEmails = allowedEmailsRaw
|
|
55
|
+
? allowedEmailsRaw.split(',').map((e: string) => e.trim())
|
|
56
|
+
: undefined
|
|
57
|
+
|
|
58
|
+
try {
|
|
59
|
+
let sessionResult
|
|
60
|
+
|
|
61
|
+
if (provider === 'google') {
|
|
62
|
+
const auth = createGoogleAuth({
|
|
63
|
+
clientId: import.meta.env.GOOGLE_CLIENT_ID ?? process.env.GOOGLE_CLIENT_ID ?? '',
|
|
64
|
+
clientSecret: import.meta.env.GOOGLE_CLIENT_SECRET ?? process.env.GOOGLE_CLIENT_SECRET ?? '',
|
|
65
|
+
redirectUri,
|
|
66
|
+
allowedEmails,
|
|
67
|
+
})
|
|
68
|
+
sessionResult = await auth.handleCallback(code, 'google')
|
|
69
|
+
} else {
|
|
70
|
+
const auth = createGitHubAuth({
|
|
71
|
+
clientId: import.meta.env.GITHUB_CLIENT_ID ?? process.env.GITHUB_CLIENT_ID ?? '',
|
|
72
|
+
clientSecret: import.meta.env.GITHUB_CLIENT_SECRET ?? process.env.GITHUB_CLIENT_SECRET ?? '',
|
|
73
|
+
redirectUri,
|
|
74
|
+
allowedEmails,
|
|
75
|
+
})
|
|
76
|
+
sessionResult = await auth.handleCallback(code, 'github')
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (!sessionResult.ok) {
|
|
80
|
+
console.error('[setzkasten] Auth failed:', sessionResult.error.message)
|
|
81
|
+
return new Response(`Authentication failed: ${sessionResult.error.message}`, { status: 403 })
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const session = sessionResult.value
|
|
85
|
+
|
|
86
|
+
// Set session cookie with user info (HMAC-signed in production)
|
|
87
|
+
const sessionPayload = JSON.stringify({
|
|
88
|
+
user: session.user,
|
|
89
|
+
expiresAt: session.expiresAt,
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
cookies.set('setzkasten_session', sessionPayload, {
|
|
93
|
+
httpOnly: true,
|
|
94
|
+
secure: import.meta.env.PROD,
|
|
95
|
+
sameSite: 'lax',
|
|
96
|
+
path: '/',
|
|
97
|
+
maxAge: 60 * 60 * 24 * 7, // 7 days
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
return redirect(adminPath)
|
|
101
|
+
} catch (error) {
|
|
102
|
+
console.error('[setzkasten] Auth callback error:', error)
|
|
103
|
+
return new Response('Authentication failed', { status: 500 })
|
|
104
|
+
}
|
|
105
|
+
}
|