@setzkasten-cms/astro-admin 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/LICENSE +37 -0
  2. package/package.json +70 -0
  3. package/src/admin-page.astro +148 -0
  4. package/src/api-routes/__tests__/add-section-helpers.test.ts +383 -0
  5. package/src/api-routes/__tests__/catalog-api.test.ts +115 -0
  6. package/src/api-routes/__tests__/deferred-operations.test.ts +232 -0
  7. package/src/api-routes/__tests__/deploy-hook.test.ts +134 -0
  8. package/src/api-routes/__tests__/patch-page-file.test.ts +193 -0
  9. package/src/api-routes/__tests__/scan-page-helpers.test.ts +162 -0
  10. package/src/api-routes/__tests__/section-management.test.ts +284 -0
  11. package/src/api-routes/_storage-config.ts +54 -0
  12. package/src/api-routes/asset-proxy.ts +76 -0
  13. package/src/api-routes/auth-callback.ts +105 -0
  14. package/src/api-routes/auth-login.ts +87 -0
  15. package/src/api-routes/auth-logout.ts +9 -0
  16. package/src/api-routes/auth-session.ts +36 -0
  17. package/src/api-routes/catalog-add.ts +151 -0
  18. package/src/api-routes/catalog-export.ts +86 -0
  19. package/src/api-routes/catalog-helpers.ts +83 -0
  20. package/src/api-routes/catalog-list.ts +12 -0
  21. package/src/api-routes/config.ts +30 -0
  22. package/src/api-routes/deploy-hook.ts +69 -0
  23. package/src/api-routes/github-proxy.ts +111 -0
  24. package/src/api-routes/init-add-section.ts +511 -0
  25. package/src/api-routes/init-apply.ts +270 -0
  26. package/src/api-routes/init-migrate.ts +262 -0
  27. package/src/api-routes/init-scan-page.ts +336 -0
  28. package/src/api-routes/init-scan.ts +162 -0
  29. package/src/api-routes/pages.ts +17 -0
  30. package/src/api-routes/section-add.ts +189 -0
  31. package/src/api-routes/section-commit-pending.ts +147 -0
  32. package/src/api-routes/section-delete.ts +141 -0
  33. package/src/api-routes/section-duplicate.ts +144 -0
  34. package/src/api-routes/section-management.ts +95 -0
  35. package/src/api-routes/section-prepare-copy.ts +93 -0
  36. package/src/api-routes/section-prepare.ts +121 -0
  37. package/src/env.d.ts +7 -0
  38. package/src/init/__tests__/page-level.test.ts +1033 -0
  39. package/src/init/__tests__/page-list-coverage.test.ts +474 -0
  40. package/src/init/__tests__/patcher-edge-cases.test.ts +434 -0
  41. package/src/init/__tests__/patcher-page-mode.test.ts +272 -0
  42. package/src/init/__tests__/section-pipeline.test.ts +393 -0
  43. package/src/init/analyzer-types.ts +92 -0
  44. package/src/init/astro-config-patcher.ts +98 -0
  45. package/src/init/astro-detector.ts +207 -0
  46. package/src/init/astro-section-analyzer-v2.ts +1663 -0
  47. package/src/init/field-label-enricher.ts +72 -0
  48. package/src/init/template-patcher-v2.ts +1957 -0
  49. package/tsconfig.json +9 -0
package/LICENSE ADDED
@@ -0,0 +1,37 @@
1
+ Setzkasten Community License
2
+
3
+ Copyright (c) 2026 Lilapixel
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to use,
7
+ copy, modify, merge, publish, and distribute the Software, subject to the
8
+ following conditions:
9
+
10
+ 1. The above copyright notice and this permission notice shall be included in
11
+ all copies or substantial portions of the Software.
12
+
13
+ 2. The Software may not be used for commercial purposes without a separate
14
+ commercial license from the copyright holder. "Commercial purposes" means
15
+ any use of the Software that is primarily intended for or directed toward
16
+ commercial advantage or monetary compensation. This includes, but is not
17
+ limited to:
18
+ - Using the Software to manage content for a commercial website or product
19
+ - Offering the Software as part of a paid service
20
+ - Using the Software within a for-profit organization
21
+
22
+ 3. Non-commercial use is permitted without restriction. This includes:
23
+ - Personal projects
24
+ - Open source projects
25
+ - Educational and academic use
26
+ - Non-profit organizations
27
+
28
+ 4. A commercial license ("Enterprise License") may be obtained by contacting
29
+ Lilapixel at hello@lilapixel.de.
30
+
31
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
32
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
33
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
34
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
35
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
36
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
37
+ SOFTWARE.
package/package.json ADDED
@@ -0,0 +1,70 @@
1
+ {
2
+ "name": "@setzkasten-cms/astro-admin",
3
+ "version": "0.6.0",
4
+ "description": "Setzkasten Admin-UI, Init-Wizard und Adoptions-Pipeline für Astro",
5
+ "type": "module",
6
+ "license": "SEE LICENSE IN LICENSE",
7
+ "author": "Lilapixel <hello@lilapixel.de>",
8
+ "homepage": "https://github.com/thosor87/setzkasten/tree/main/packages/astro-admin#readme",
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "https://github.com/thosor87/setzkasten.git",
12
+ "directory": "packages/astro-admin"
13
+ },
14
+ "bugs": "https://github.com/thosor87/setzkasten/issues",
15
+ "keywords": [
16
+ "cms",
17
+ "setzkasten",
18
+ "astro",
19
+ "admin",
20
+ "git-based",
21
+ "headless-cms"
22
+ ],
23
+ "exports": {
24
+ "./auth-login": "./src/api-routes/auth-login.ts",
25
+ "./auth-callback": "./src/api-routes/auth-callback.ts",
26
+ "./auth-logout": "./src/api-routes/auth-logout.ts",
27
+ "./auth-session": "./src/api-routes/auth-session.ts",
28
+ "./github-proxy": "./src/api-routes/github-proxy.ts",
29
+ "./asset-proxy": "./src/api-routes/asset-proxy.ts",
30
+ "./config": "./src/api-routes/config.ts",
31
+ "./pages": "./src/api-routes/pages.ts",
32
+ "./init-scan": "./src/api-routes/init-scan.ts",
33
+ "./init-apply": "./src/api-routes/init-apply.ts",
34
+ "./init-scan-page": "./src/api-routes/init-scan-page.ts",
35
+ "./init-add-section": "./src/api-routes/init-add-section.ts",
36
+ "./init-migrate": "./src/api-routes/init-migrate.ts",
37
+ "./deploy-hook": "./src/api-routes/deploy-hook.ts",
38
+ "./catalog": "./src/api-routes/catalog-list.ts",
39
+ "./catalog-add": "./src/api-routes/catalog-add.ts",
40
+ "./catalog-export": "./src/api-routes/catalog-export.ts",
41
+ "./section-add": "./src/api-routes/section-add.ts",
42
+ "./section-prepare": "./src/api-routes/section-prepare.ts",
43
+ "./section-prepare-copy": "./src/api-routes/section-prepare-copy.ts",
44
+ "./section-commit-pending": "./src/api-routes/section-commit-pending.ts",
45
+ "./section-delete": "./src/api-routes/section-delete.ts",
46
+ "./section-duplicate": "./src/api-routes/section-duplicate.ts",
47
+ "./admin-page": "./src/admin-page.astro"
48
+ },
49
+ "devDependencies": {
50
+ "vitest": "^3.2.0"
51
+ },
52
+ "dependencies": {
53
+ "@astrojs/compiler": "^3.0.0",
54
+ "@setzkasten-cms/auth": "0.6.0",
55
+ "@setzkasten-cms/catalog": "0.6.0",
56
+ "@setzkasten-cms/core": "0.6.0",
57
+ "@setzkasten-cms/ui": "0.6.0",
58
+ "@setzkasten-cms/github-adapter": "0.6.0"
59
+ },
60
+ "peerDependencies": {
61
+ "astro": "^5.0.0",
62
+ "react": "^19.0.0",
63
+ "react-dom": "^19.0.0"
64
+ },
65
+ "scripts": {
66
+ "typecheck": "tsc --noEmit",
67
+ "test": "vitest run",
68
+ "test:watch": "vitest"
69
+ }
70
+ }
@@ -0,0 +1,148 @@
1
+ ---
2
+ /**
3
+ * Admin SPA shell – served at /admin/[...path]
4
+ * Mounts the React-based AdminApp as a client-side SPA.
5
+ */
6
+ ---
7
+
8
+ <!doctype html>
9
+ <html lang="de">
10
+ <head>
11
+ <meta charset="UTF-8" />
12
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
13
+ <meta name="robots" content="noindex, nofollow" />
14
+ <title>Setzkasten Admin</title>
15
+ <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
16
+ <link rel="preconnect" href="https://fonts.googleapis.com" />
17
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
18
+ <link href="https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,400;0,9..40,500;0,9..40,600;0,9..40,700;0,9..40,800;1,9..40,400&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet" />
19
+ <style>
20
+ * { box-sizing: border-box; margin: 0; padding: 0; }
21
+ body {
22
+ font-family: 'DM Sans', system-ui, sans-serif;
23
+ color: #1a1a2e;
24
+ background: #faf9f7;
25
+ -webkit-font-smoothing: antialiased;
26
+ }
27
+ .sk-boot-loading {
28
+ display: flex;
29
+ align-items: center;
30
+ justify-content: center;
31
+ min-height: 100vh;
32
+ flex-direction: column;
33
+ gap: 16px;
34
+ }
35
+ .sk-boot-spinner {
36
+ width: 32px;
37
+ height: 32px;
38
+ border: 3px solid #e2ddd7;
39
+ border-top-color: #c45d3e;
40
+ border-radius: 50%;
41
+ animation: sk-boot-spin 0.8s linear infinite;
42
+ }
43
+ @keyframes sk-boot-spin {
44
+ to { transform: rotate(360deg); }
45
+ }
46
+ </style>
47
+ </head>
48
+
49
+ <body>
50
+ <div id="setzkasten-admin">
51
+ <div class="sk-boot-loading">
52
+ <div class="sk-boot-spinner"></div>
53
+ <div style="font-size: 14px; color: #64748b;">Lade Setzkasten...</div>
54
+ </div>
55
+ </div>
56
+
57
+ <script>
58
+ import React, { createElement } from 'react'
59
+ import { createRoot } from 'react-dom/client'
60
+ // Tiptap uses React.Component/React.createRef globally
61
+ ;(globalThis as any).React = React
62
+ import { SetzKastenProvider, AdminApp, ProxyContentRepository, ProxyAssetStore } from '@setzkasten-cms/ui'
63
+ import '@setzkasten-cms/ui/styles/admin.css'
64
+
65
+ async function boot() {
66
+ const root = document.getElementById('setzkasten-admin')
67
+ if (!root) return
68
+
69
+ try {
70
+ const injected = (globalThis as any).__SETZKASTEN_CONFIG__ ?? {}
71
+
72
+ const providers: Array<'github' | 'google'> = []
73
+ if (injected.hasGitHub) providers.push('github')
74
+ if (injected.hasGoogle) providers.push('google')
75
+ if (providers.length === 0) providers.push('github')
76
+
77
+ // Fetch the full user config from server
78
+ let userConfig: any = null
79
+ try {
80
+ const res = await fetch('/api/setzkasten/config')
81
+ if (res.ok) userConfig = await res.json()
82
+ } catch {}
83
+
84
+ const skConfig = userConfig ?? {
85
+ storage: { kind: 'github' as const },
86
+ auth: { providers },
87
+ theme: {},
88
+ products: {},
89
+ collections: {},
90
+ }
91
+
92
+ // Storage params come from the config API (server-injected via SSR)
93
+ const storage = userConfig?._storage ?? injected.storage ?? {}
94
+ const owner = storage.owner ?? ''
95
+ const repo = storage.repo ?? ''
96
+ const branch = storage.branch ?? 'main'
97
+ const contentPath = storage.contentPath ?? 'content'
98
+
99
+ const repository = new ProxyContentRepository({
100
+ proxyBaseUrl: '/api/setzkasten/github',
101
+ owner,
102
+ repo,
103
+ branch,
104
+ contentPath,
105
+ })
106
+
107
+ const assetsPath = storage.assetsPath ?? 'public/images'
108
+ const assets = new ProxyAssetStore({
109
+ proxyBaseUrl: '/api/setzkasten/github',
110
+ owner,
111
+ repo,
112
+ branch,
113
+ assetsPath,
114
+ publicUrlPrefix: '/images',
115
+ })
116
+
117
+ const auth = {
118
+ async login() { window.location.href = '/api/setzkasten/auth/login?provider=github' },
119
+ async logout() { window.location.href = '/api/setzkasten/auth/logout' },
120
+ async getSession() {
121
+ const res = await fetch('/api/setzkasten/auth/session')
122
+ return res.json()
123
+ },
124
+ }
125
+
126
+ const reactRoot = createRoot(root)
127
+ reactRoot.render(
128
+ createElement(
129
+ SetzKastenProvider,
130
+ { config: skConfig, repository, auth, assets },
131
+ createElement(AdminApp)
132
+ )
133
+ )
134
+ } catch (error) {
135
+ console.error('[setzkasten] Boot failed:', error)
136
+ root.innerHTML = `
137
+ <div style="display:flex;align-items:center;justify-content:center;min-height:100vh;flex-direction:column;gap:16px;">
138
+ <p style="color:#ef4444;font-size:14px;">Fehler beim Laden des Admin-Panels.</p>
139
+ <a href="/" style="color:#64748b;font-size:13px;">Zur Startseite</a>
140
+ </div>
141
+ `
142
+ }
143
+ }
144
+
145
+ boot()
146
+ </script>
147
+ </body>
148
+ </html>
@@ -0,0 +1,383 @@
1
+ /**
2
+ * Unit tests for the pure helper logic in init-add-section.ts.
3
+ *
4
+ * These functions contain non-trivial logic that has caused bugs or could
5
+ * silently produce wrong output. They are extracted here so they can be
6
+ * tested without mocking the GitHub API.
7
+ *
8
+ * Covered:
9
+ * 1. pageKey → configKey separator normalisation ('/' → '_', not '--')
10
+ * 2. Content JSON generation: field ordering, defaultValue fallback, merge
11
+ * 3. Page-config JSON: section deduplication (normalizeKey logic)
12
+ * 4. sk-preview clone: prerender removal, import path adjustment
13
+ * 5. patchPageFile: import injection, registry entry, hardcoded tag removal
14
+ * 6. calculateRelativePath: correct relative paths for various depths
15
+ */
16
+
17
+ import { describe, it, expect } from 'vitest'
18
+
19
+ // ---------------------------------------------------------------------------
20
+ // Helpers under test — re-implemented inline so we can test them without
21
+ // importing the whole APIRoute (which has Astro / env deps).
22
+ //
23
+ // These implementations MUST stay in sync with init-add-section.ts.
24
+ // If you change the real implementation, update these copies too.
25
+ // ---------------------------------------------------------------------------
26
+
27
+ function getDefaultValue(fieldType: string): unknown {
28
+ switch (fieldType) {
29
+ case 'text': return ''
30
+ case 'number': return 0
31
+ case 'boolean': return false
32
+ case 'image': return { path: '', alt: '' }
33
+ case 'array': return []
34
+ case 'color': return '#000000'
35
+ case 'date': return ''
36
+ case 'icon': return ''
37
+ default: return ''
38
+ }
39
+ }
40
+
41
+ function calculateRelativePath(fromDir: string, toPath: string): string {
42
+ const fromParts = fromDir.split('/')
43
+ const toParts = toPath.split('/')
44
+ let common = 0
45
+ while (
46
+ common < fromParts.length &&
47
+ common < toParts.length &&
48
+ fromParts[common] === toParts[common]
49
+ ) {
50
+ common++
51
+ }
52
+ const ups = fromParts.length - common
53
+ const remaining = toParts.slice(common).join('/')
54
+ if (ups === 0) return './' + remaining
55
+ return '../'.repeat(ups) + remaining
56
+ }
57
+
58
+ /** The normalize helper used in the section-dedup logic */
59
+ const normalizeKey = (k: string) => k.replace(/[-_]/g, '').toLowerCase()
60
+
61
+ /** Config-key computation used in both backend and UI */
62
+ function pageKeyToConfigKey(pageKey: string): string {
63
+ return '_' + pageKey.replace(/\//g, '_')
64
+ }
65
+
66
+ /** Build content JSON with field ordering and merge logic */
67
+ function buildSectionData(
68
+ existingData: Record<string, unknown>,
69
+ fields: Array<{ key: string; type: string; defaultValue?: unknown }>,
70
+ orderedKeys: string[],
71
+ ): Record<string, unknown> {
72
+ const sectionData = { ...existingData }
73
+
74
+ // Only add values for fields that don't already exist
75
+ for (const field of fields) {
76
+ if (!(field.key in sectionData)) {
77
+ sectionData[field.key] = field.defaultValue ?? getDefaultValue(field.type)
78
+ }
79
+ }
80
+
81
+ // Reorder keys to match template order
82
+ const orderedData: Record<string, unknown> = {}
83
+ for (const key of orderedKeys) {
84
+ if (key in sectionData) orderedData[key] = sectionData[key]
85
+ }
86
+ for (const key of Object.keys(sectionData)) {
87
+ if (!(key in orderedData)) orderedData[key] = sectionData[key]
88
+ }
89
+
90
+ return orderedData
91
+ }
92
+
93
+ /** Create or update a page-config JSON, deduplicating sections */
94
+ function buildPageConfig(
95
+ existingJson: string | null,
96
+ sectionKey: string,
97
+ ): { sections: Array<{ key: string; enabled: boolean }> } {
98
+ if (existingJson) {
99
+ const config = JSON.parse(existingJson)
100
+ const normalizedNewKey = normalizeKey(sectionKey)
101
+ if (!config.sections.some((s: { key: string }) => normalizeKey(s.key) === normalizedNewKey)) {
102
+ config.sections.push({ key: sectionKey, enabled: true })
103
+ }
104
+ return config
105
+ }
106
+ return { sections: [{ key: sectionKey, enabled: true }] }
107
+ }
108
+
109
+ /** Build the sk-preview clone: strip prerender, fix import depths */
110
+ function buildPreviewClone(patchedSource: string): string {
111
+ return patchedSource
112
+ .replace(/\bexport\s+const\s+prerender\s*=\s*true\s*;?\s*\n?/, '')
113
+ .replace(/(from\s+')(\.\.\/)/g, '$1../$2')
114
+ .replace(/(from\s+")(\.\.\/)/g, '$1../$2')
115
+ }
116
+
117
+ // ---------------------------------------------------------------------------
118
+ // 1. pageKey → configKey
119
+ // ---------------------------------------------------------------------------
120
+
121
+ describe('pageKey → configKey normalisation', () => {
122
+ it('should use underscore separators for nested pages', () => {
123
+ expect(pageKeyToConfigKey('docs/architecture')).toBe('_docs_architecture')
124
+ })
125
+
126
+ it('should not use double-dash separators', () => {
127
+ expect(pageKeyToConfigKey('docs/architecture')).not.toContain('--')
128
+ })
129
+
130
+ it('should handle single-level page', () => {
131
+ expect(pageKeyToConfigKey('about')).toBe('_about')
132
+ })
133
+
134
+ it('should handle index page', () => {
135
+ expect(pageKeyToConfigKey('index')).toBe('_index')
136
+ })
137
+
138
+ it('should handle three-level nesting', () => {
139
+ expect(pageKeyToConfigKey('docs/api/reference')).toBe('_docs_api_reference')
140
+ })
141
+ })
142
+
143
+ // ---------------------------------------------------------------------------
144
+ // 2. Content JSON — field ordering and merge
145
+ // ---------------------------------------------------------------------------
146
+
147
+ describe('Content JSON generation', () => {
148
+ const fields = [
149
+ { key: 'heading', type: 'text', defaultValue: 'Hello' },
150
+ { key: 'count', type: 'number', defaultValue: 42 },
151
+ { key: 'active', type: 'boolean' },
152
+ { key: 'items', type: 'array', defaultValue: ['a', 'b'] },
153
+ ]
154
+
155
+ it('should use defaultValue when provided', () => {
156
+ const data = buildSectionData({}, fields, fields.map(f => f.key))
157
+ expect(data.heading).toBe('Hello')
158
+ expect(data.count).toBe(42)
159
+ expect(data.items).toEqual(['a', 'b'])
160
+ })
161
+
162
+ it('should fall back to getDefaultValue when defaultValue is absent', () => {
163
+ const data = buildSectionData({}, fields, fields.map(f => f.key))
164
+ expect(data.active).toBe(false)
165
+ })
166
+
167
+ it('should not overwrite existing values on re-adoption', () => {
168
+ const existing = { heading: 'Existing Title', count: 99 }
169
+ const data = buildSectionData(existing, fields, fields.map(f => f.key))
170
+ expect(data.heading).toBe('Existing Title')
171
+ expect(data.count).toBe(99)
172
+ })
173
+
174
+ it('should respect template field order', () => {
175
+ const orderedKeys = ['items', 'count', 'heading', 'active']
176
+ const data = buildSectionData({}, fields, orderedKeys)
177
+ expect(Object.keys(data)).toEqual(['items', 'count', 'heading', 'active'])
178
+ })
179
+
180
+ it('should getDefaultValue correctly for all field types', () => {
181
+ expect(getDefaultValue('text')).toBe('')
182
+ expect(getDefaultValue('number')).toBe(0)
183
+ expect(getDefaultValue('boolean')).toBe(false)
184
+ expect(getDefaultValue('image')).toEqual({ path: '', alt: '' })
185
+ expect(getDefaultValue('array')).toEqual([])
186
+ expect(getDefaultValue('color')).toBe('#000000')
187
+ expect(getDefaultValue('unknown')).toBe('')
188
+ })
189
+ })
190
+
191
+ // ---------------------------------------------------------------------------
192
+ // 3. Page config JSON — section deduplication
193
+ // ---------------------------------------------------------------------------
194
+
195
+ describe('Page config — section deduplication', () => {
196
+ it('should create a new config when none exists', () => {
197
+ const config = buildPageConfig(null, 'hero')
198
+ expect(config.sections).toEqual([{ key: 'hero', enabled: true }])
199
+ })
200
+
201
+ it('should append a new section to existing config', () => {
202
+ const existing = JSON.stringify({ sections: [{ key: 'hero', enabled: true }] })
203
+ const config = buildPageConfig(existing, 'features')
204
+ expect(config.sections).toHaveLength(2)
205
+ expect(config.sections[1]).toEqual({ key: 'features', enabled: true })
206
+ })
207
+
208
+ it('should not add a duplicate section (same key)', () => {
209
+ const existing = JSON.stringify({ sections: [{ key: 'hero', enabled: true }] })
210
+ const config = buildPageConfig(existing, 'hero')
211
+ expect(config.sections).toHaveLength(1)
212
+ })
213
+
214
+ it('should not add a duplicate section (dash vs underscore variant)', () => {
215
+ const existing = JSON.stringify({ sections: [{ key: 'hero-section', enabled: true }] })
216
+ const config = buildPageConfig(existing, 'hero_section')
217
+ expect(config.sections).toHaveLength(1)
218
+ })
219
+
220
+ it('should not add a duplicate section (case variant)', () => {
221
+ const existing = JSON.stringify({ sections: [{ key: 'HeroSection', enabled: true }] })
222
+ const config = buildPageConfig(existing, 'herosection')
223
+ expect(config.sections).toHaveLength(1)
224
+ })
225
+ })
226
+
227
+ // ---------------------------------------------------------------------------
228
+ // 4. sk-preview clone — prerender removal and import depth fix
229
+ // ---------------------------------------------------------------------------
230
+
231
+ describe('sk-preview clone generation', () => {
232
+ const patched = `---
233
+ export const prerender = true;
234
+ import BaseLayout from '../../layouts/BaseLayout.astro';
235
+ import { getSection } from 'setzkasten:content'
236
+ const skData = getSection('_page_docs_architecture')
237
+ ---
238
+
239
+ <BaseLayout>
240
+ <section id="section-_page_docs_architecture">
241
+ <h1 set:html={skData?.heading ?? 'Architektur'} />
242
+ </section>
243
+ </BaseLayout>
244
+ `
245
+
246
+ it('should remove the prerender export', () => {
247
+ const clone = buildPreviewClone(patched)
248
+ expect(clone).not.toContain('export const prerender')
249
+ })
250
+
251
+ it('should add an extra ../ level to relative imports', () => {
252
+ const clone = buildPreviewClone(patched)
253
+ // Original: '../../layouts/BaseLayout.astro'
254
+ // After fix: '../../../layouts/BaseLayout.astro'
255
+ expect(clone).toContain("from '../../../layouts/BaseLayout.astro'")
256
+ })
257
+
258
+ it('should not touch absolute module imports', () => {
259
+ const clone = buildPreviewClone(patched)
260
+ expect(clone).toContain("from 'setzkasten:content'")
261
+ })
262
+
263
+ it('should preserve getSection call', () => {
264
+ const clone = buildPreviewClone(patched)
265
+ expect(clone).toContain("getSection('_page_docs_architecture')")
266
+ })
267
+
268
+ it('should handle double-quoted imports too', () => {
269
+ const dq = patched.replace("from '../../layouts", 'from "../../layouts').replace("BaseLayout.astro'", 'BaseLayout.astro"')
270
+ const clone = buildPreviewClone(dq)
271
+ expect(clone).toContain('../../../layouts/BaseLayout.astro')
272
+ })
273
+ })
274
+
275
+ // ---------------------------------------------------------------------------
276
+ // 5. calculateRelativePath
277
+ // ---------------------------------------------------------------------------
278
+
279
+ describe('calculateRelativePath', () => {
280
+ it('same directory', () => {
281
+ expect(calculateRelativePath('src/components', 'src/components/Hero.astro'))
282
+ .toBe('./Hero.astro')
283
+ })
284
+
285
+ it('one level up', () => {
286
+ expect(calculateRelativePath('src/pages', 'src/components/sections/HeroSection.astro'))
287
+ .toBe('../components/sections/HeroSection.astro')
288
+ })
289
+
290
+ it('two levels up', () => {
291
+ expect(calculateRelativePath('src/pages/docs', 'src/components/sections/HeroSection.astro'))
292
+ .toBe('../../components/sections/HeroSection.astro')
293
+ })
294
+
295
+ it('completely different paths', () => {
296
+ expect(calculateRelativePath('apps/website/src/pages', 'apps/website/src/components/sections/Footer.astro'))
297
+ .toBe('../components/sections/Footer.astro')
298
+ })
299
+
300
+ it('component in same folder as page', () => {
301
+ expect(calculateRelativePath('src/pages', 'src/pages/HeroSection.astro'))
302
+ .toBe('./HeroSection.astro')
303
+ })
304
+ })
305
+
306
+ // ---------------------------------------------------------------------------
307
+ // 6. Directory-route clone path — the regression that caused build failures
308
+ //
309
+ // When the UI sends pagePath='src/pages/docs.astro' but the actual file is
310
+ // src/pages/docs/index.astro (directory route), the sk-preview clone must
311
+ // be placed at sk-preview/docs/index.astro (NOT sk-preview/docs.astro).
312
+ //
313
+ // sk-preview/docs.astro (wrong): same depth as src/pages/sk-preview/
314
+ // → buildPreviewClone adds "../" → "../../layouts/" becomes "../../../layouts/" ✗
315
+ //
316
+ // sk-preview/docs/index.astro (correct): one level deeper in sk-preview/docs/
317
+ // → buildPreviewClone adds "../" → "../../layouts/" becomes "../../../layouts/" ✓
318
+ // ---------------------------------------------------------------------------
319
+
320
+ /** Simulate resolvedPagePath logic from init-add-section.ts step 4 */
321
+ function resolvePagePath(bodyPagePath: string, fileExistsOnGitHub: (path: string) => boolean): string {
322
+ if (fileExistsOnGitHub(bodyPagePath)) return bodyPagePath
323
+ if (bodyPagePath.endsWith('.astro')) {
324
+ const indexPath = bodyPagePath.replace(/\.astro$/, '/index.astro')
325
+ if (fileExistsOnGitHub(indexPath)) return indexPath
326
+ }
327
+ return bodyPagePath
328
+ }
329
+
330
+ describe('Directory-route clone path (build-failure regression)', () => {
331
+ it('top-level page: resolvedPagePath = bodyPagePath (no fallback needed)', () => {
332
+ const resolved = resolvePagePath('src/pages/about.astro', (p) => p === 'src/pages/about.astro')
333
+ expect(resolved).toBe('src/pages/about.astro')
334
+ })
335
+
336
+ it('directory route: resolvedPagePath falls back to index.astro', () => {
337
+ // docs.astro does not exist, docs/index.astro does
338
+ const resolved = resolvePagePath(
339
+ 'src/pages/docs.astro',
340
+ (p) => p === 'src/pages/docs/index.astro',
341
+ )
342
+ expect(resolved).toBe('src/pages/docs/index.astro')
343
+ })
344
+
345
+ it('directory route: clone relativePage uses resolved path, not body path', () => {
346
+ const bodyPagePath = 'src/pages/docs.astro'
347
+ const resolved = resolvePagePath(
348
+ bodyPagePath,
349
+ (p) => p === 'src/pages/docs/index.astro',
350
+ )
351
+ const relativePage = resolved.replace(/^src\/pages\//, '')
352
+ // Must be 'docs/index.astro', NOT 'docs.astro'
353
+ expect(relativePage).toBe('docs/index.astro')
354
+ expect(relativePage).not.toBe('docs.astro')
355
+ })
356
+
357
+ it('directory route clone gets correct import depth after buildPreviewClone', () => {
358
+ // src/pages/docs/index.astro imports '../../layouts/BaseLayout.astro'
359
+ // clone at sk-preview/docs/index.astro needs '../../../layouts/BaseLayout.astro'
360
+ const source = `---
361
+ import BaseLayout from '../../layouts/BaseLayout.astro';
362
+ import { getSection } from 'setzkasten:content'
363
+ const skData = getSection('_page_docs')
364
+ ---
365
+ <BaseLayout><slot /></BaseLayout>
366
+ `
367
+ const clone = buildPreviewClone(source)
368
+ expect(clone).toContain("from '../../../layouts/BaseLayout.astro'")
369
+ })
370
+
371
+ it('wrong clone path (docs.astro) would have incorrect import depth', () => {
372
+ // sk-preview/docs.astro is at same depth as sk-preview/*.astro
373
+ // It needs '../../layouts/' but buildPreviewClone would produce '../../../layouts/'
374
+ // This test documents the bug: if relativePage were 'docs.astro' instead of
375
+ // 'docs/index.astro', the clone ends up at the wrong path and imports break.
376
+ const bodyPagePath = 'src/pages/docs.astro'
377
+ const wrongRelativePage = bodyPagePath.replace(/^src\/pages\//, '') // 'docs.astro' ← the bug
378
+ expect(wrongRelativePage).toBe('docs.astro')
379
+ // The CORRECT relative page (after fix):
380
+ const correctRelativePage = 'src/pages/docs/index.astro'.replace(/^src\/pages\//, '')
381
+ expect(correctRelativePage).toBe('docs/index.astro')
382
+ })
383
+ })