@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
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
|
+
})
|