@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,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure helper functions for section delete and duplicate operations.
|
|
3
|
+
* No GitHub API — callers handle storage.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
interface SectionEntry {
|
|
7
|
+
key: string
|
|
8
|
+
type?: string
|
|
9
|
+
enabled?: boolean
|
|
10
|
+
order?: number
|
|
11
|
+
[key: string]: unknown
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface PageConfig {
|
|
15
|
+
sections: SectionEntry[]
|
|
16
|
+
[key: string]: unknown
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Removes a section from the page config and re-numbers order.
|
|
21
|
+
*/
|
|
22
|
+
export function removeFromPageConfig(config: PageConfig, sectionKey: string): PageConfig {
|
|
23
|
+
const sections = config.sections
|
|
24
|
+
.filter(s => s.key !== sectionKey)
|
|
25
|
+
.map((s, i) => ({ ...s, order: i }))
|
|
26
|
+
return { ...config, sections }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Generates a unique duplicate key for a section.
|
|
31
|
+
* 'hero' → 'hero--copy', then 'hero--copy2', 'hero--copy3', ...
|
|
32
|
+
*/
|
|
33
|
+
export function generateDuplicateKey(existingKeys: string[], originalKey: string): string {
|
|
34
|
+
const base = `${originalKey}--copy`
|
|
35
|
+
if (!existingKeys.includes(base)) return base
|
|
36
|
+
let n = 2
|
|
37
|
+
while (existingKeys.includes(`${base}${n}`)) n++
|
|
38
|
+
return `${base}${n}`
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Generates a unique key for a new section of a given type.
|
|
43
|
+
* 'hero' → 'hero' (if free), then 'hero--2', 'hero--3', ...
|
|
44
|
+
*/
|
|
45
|
+
export function generateAddKey(existingKeys: string[], type: string): string {
|
|
46
|
+
if (!existingKeys.includes(type)) return type
|
|
47
|
+
let n = 2
|
|
48
|
+
while (existingKeys.includes(`${type}--${n}`)) n++
|
|
49
|
+
return `${type}--${n}`
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Appends a new section entry at the end of the page config.
|
|
54
|
+
* Sets `type` only when key differs from type (multi-instance case).
|
|
55
|
+
*/
|
|
56
|
+
export function addToPageConfig(
|
|
57
|
+
config: PageConfig,
|
|
58
|
+
key: string,
|
|
59
|
+
type: string,
|
|
60
|
+
): PageConfig {
|
|
61
|
+
const entry: SectionEntry = {
|
|
62
|
+
key,
|
|
63
|
+
enabled: true,
|
|
64
|
+
order: config.sections.length,
|
|
65
|
+
...(key !== type ? { type } : {}),
|
|
66
|
+
}
|
|
67
|
+
return { ...config, sections: [...config.sections, entry] }
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Inserts a duplicate entry immediately after the original in the page config.
|
|
72
|
+
* The copy is always enabled. Order is re-numbered.
|
|
73
|
+
*/
|
|
74
|
+
export function duplicateInPageConfig(
|
|
75
|
+
config: PageConfig,
|
|
76
|
+
originalKey: string,
|
|
77
|
+
newKey: string,
|
|
78
|
+
): PageConfig {
|
|
79
|
+
const original = config.sections.find(s => s.key === originalKey)
|
|
80
|
+
if (!original) return config
|
|
81
|
+
|
|
82
|
+
// Always set type explicitly: copy key differs from type (e.g. 'testPricing--copy'),
|
|
83
|
+
// so getSectionDef / resolveSectionType can find the original type, not the copy key.
|
|
84
|
+
const resolvedType = original.type ?? originalKey
|
|
85
|
+
const copy: SectionEntry = { ...original, key: newKey, enabled: true, type: resolvedType }
|
|
86
|
+
|
|
87
|
+
const insertAfter = config.sections.indexOf(original)
|
|
88
|
+
const sections = [
|
|
89
|
+
...config.sections.slice(0, insertAfter + 1),
|
|
90
|
+
copy,
|
|
91
|
+
...config.sections.slice(insertAfter + 1),
|
|
92
|
+
].map((s, i) => ({ ...s, order: i }))
|
|
93
|
+
|
|
94
|
+
return { ...config, sections }
|
|
95
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import type { APIRoute } from 'astro'
|
|
2
|
+
import { resolveStorageConfig } from './_storage-config'
|
|
3
|
+
import { generateDuplicateKey, duplicateInPageConfig } from './section-management'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* POST /api/setzkasten/sections/prepare-copy
|
|
7
|
+
*
|
|
8
|
+
* Prepares a deferred duplicate of an existing section:
|
|
9
|
+
* - Reads the original section content from GitHub
|
|
10
|
+
* - Generates a unique copy key (hero → hero--copy → hero--copy2 …)
|
|
11
|
+
* - Returns { key, type, content, updatedPageConfig } WITHOUT committing
|
|
12
|
+
*
|
|
13
|
+
* The client uses this to update local state + preview draft immediately.
|
|
14
|
+
* Only committed to GitHub when the user presses "Live setzen".
|
|
15
|
+
*
|
|
16
|
+
* Body: { pageKey, sectionKey, owner?, repo?, branch?, contentPath? }
|
|
17
|
+
*/
|
|
18
|
+
export const POST: APIRoute = async ({ request, cookies }) => {
|
|
19
|
+
const session = cookies.get('setzkasten_session')?.value
|
|
20
|
+
if (!session) return new Response('Unauthorized', { status: 401 })
|
|
21
|
+
|
|
22
|
+
const githubToken = import.meta.env.GITHUB_TOKEN ?? process.env.GITHUB_TOKEN
|
|
23
|
+
if (!githubToken) return new Response('GitHub token not configured', { status: 500 })
|
|
24
|
+
|
|
25
|
+
try {
|
|
26
|
+
const body = await request.json() as {
|
|
27
|
+
pageKey: string
|
|
28
|
+
sectionKey: string
|
|
29
|
+
owner?: string
|
|
30
|
+
repo?: string
|
|
31
|
+
branch?: string
|
|
32
|
+
contentPath?: string
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const storage = resolveStorageConfig(body)
|
|
36
|
+
if (!storage) return Response.json({ error: 'Could not resolve owner/repo' }, { status: 400 })
|
|
37
|
+
const { owner, repo, branch } = storage
|
|
38
|
+
|
|
39
|
+
const serverConfig = (globalThis as any).__SETZKASTEN_CONFIG__
|
|
40
|
+
const contentPath = body.contentPath || serverConfig?.storage?.contentPath || 'content'
|
|
41
|
+
const { pageKey, sectionKey } = body
|
|
42
|
+
|
|
43
|
+
if (!pageKey || !sectionKey) {
|
|
44
|
+
return Response.json({ error: 'pageKey and sectionKey are required' }, { status: 400 })
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// 1. Read current page config
|
|
48
|
+
const configKey = '_' + pageKey.replace(/\//g, '_')
|
|
49
|
+
const pageConfigPath = `${contentPath}/pages/${configKey}.json`
|
|
50
|
+
const pageConfigRaw = await fetchFileContent(owner, repo, branch, pageConfigPath, githubToken)
|
|
51
|
+
if (!pageConfigRaw) return Response.json({ error: 'Page config not found' }, { status: 404 })
|
|
52
|
+
|
|
53
|
+
const pageConfig = JSON.parse(pageConfigRaw)
|
|
54
|
+
const existingKeys: string[] = (pageConfig.sections ?? []).map((s: { key: string }) => s.key)
|
|
55
|
+
|
|
56
|
+
// 2. Generate unique copy key
|
|
57
|
+
const newKey = generateDuplicateKey(existingKeys, sectionKey)
|
|
58
|
+
|
|
59
|
+
// 3. Read original section content (null-safe: fall back to empty object)
|
|
60
|
+
const originalJsonPath = `${contentPath}/_sections/${sectionKey}.json`
|
|
61
|
+
const originalRaw = await fetchFileContent(owner, repo, branch, originalJsonPath, githubToken)
|
|
62
|
+
const content: Record<string, unknown> = originalRaw ? JSON.parse(originalRaw) : {}
|
|
63
|
+
|
|
64
|
+
// 4. Determine section type (for multi-instance support)
|
|
65
|
+
const originalEntry = (pageConfig.sections ?? []).find((s: any) => s.key === sectionKey)
|
|
66
|
+
const type: string = originalEntry?.type ?? sectionKey
|
|
67
|
+
|
|
68
|
+
// 5. Build updated page config (for client-side optimistic update)
|
|
69
|
+
const updatedPageConfig = duplicateInPageConfig(pageConfig, sectionKey, newKey)
|
|
70
|
+
|
|
71
|
+
return Response.json({ key: newKey, type, content, updatedPageConfig })
|
|
72
|
+
} catch (error) {
|
|
73
|
+
console.error('[setzkasten] section-prepare-copy error:', error)
|
|
74
|
+
return Response.json(
|
|
75
|
+
{ error: error instanceof Error ? error.message : 'Prepare copy failed' },
|
|
76
|
+
{ status: 500 },
|
|
77
|
+
)
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function fetchFileContent(
|
|
82
|
+
owner: string, repo: string, branch: string, path: string, token: string,
|
|
83
|
+
): Promise<string | null> {
|
|
84
|
+
try {
|
|
85
|
+
const res = await fetch(
|
|
86
|
+
`https://api.github.com/repos/${owner}/${repo}/contents/${path}?ref=${branch}`,
|
|
87
|
+
{ headers: { Authorization: `Bearer ${token}`, Accept: 'application/vnd.github+json', 'X-GitHub-Api-Version': '2022-11-28' } },
|
|
88
|
+
)
|
|
89
|
+
if (!res.ok) return null
|
|
90
|
+
const data = await res.json() as { content: string; encoding: string }
|
|
91
|
+
return data.encoding === 'base64' ? Buffer.from(data.content, 'base64').toString('utf-8') : data.content
|
|
92
|
+
} catch { return null }
|
|
93
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import type { APIRoute } from 'astro'
|
|
2
|
+
import { resolveStorageConfig, prefixPath } from './_storage-config'
|
|
3
|
+
import { generateAddKey } from './section-management'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* POST /api/setzkasten/sections/prepare
|
|
7
|
+
*
|
|
8
|
+
* Prepares a new section for optimistic/deferred addition:
|
|
9
|
+
* - Generates a unique key for the given sectionType
|
|
10
|
+
* - Builds default content from schema (NO GitHub commit)
|
|
11
|
+
* - Returns { key, type, defaultContent, updatedPageConfig }
|
|
12
|
+
*
|
|
13
|
+
* The client uses this to update local state + preview draft immediately,
|
|
14
|
+
* and only commits to GitHub when the user explicitly saves ("Seite speichern").
|
|
15
|
+
*
|
|
16
|
+
* Body: { pageKey, sectionType, owner?, repo?, branch?, contentPath? }
|
|
17
|
+
*/
|
|
18
|
+
export const POST: APIRoute = async ({ request, cookies }) => {
|
|
19
|
+
const session = cookies.get('setzkasten_session')?.value
|
|
20
|
+
if (!session) return new Response('Unauthorized', { status: 401 })
|
|
21
|
+
|
|
22
|
+
const githubToken = import.meta.env.GITHUB_TOKEN ?? process.env.GITHUB_TOKEN
|
|
23
|
+
if (!githubToken) return new Response('GitHub token not configured', { status: 500 })
|
|
24
|
+
|
|
25
|
+
try {
|
|
26
|
+
const body = await request.json() as {
|
|
27
|
+
pageKey: string
|
|
28
|
+
sectionType: string
|
|
29
|
+
owner?: string
|
|
30
|
+
repo?: string
|
|
31
|
+
branch?: string
|
|
32
|
+
contentPath?: string
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const storage = resolveStorageConfig(body)
|
|
36
|
+
if (!storage) return Response.json({ error: 'Could not resolve owner/repo' }, { status: 400 })
|
|
37
|
+
const { owner, repo, branch } = storage
|
|
38
|
+
|
|
39
|
+
const serverConfig = (globalThis as any).__SETZKASTEN_CONFIG__
|
|
40
|
+
const fullConfig = (globalThis as any).__SETZKASTEN_FULL_CONFIG__
|
|
41
|
+
const contentPath = body.contentPath || serverConfig?.storage?.contentPath || 'content'
|
|
42
|
+
const { pageKey, sectionType } = body
|
|
43
|
+
|
|
44
|
+
if (!pageKey || !sectionType) {
|
|
45
|
+
return Response.json({ error: 'pageKey and sectionType are required' }, { status: 400 })
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// 1. Read current page config from GitHub to determine existing keys
|
|
49
|
+
const configKey = '_' + pageKey.replace(/\//g, '_')
|
|
50
|
+
const pageConfigPath = `${contentPath}/pages/${configKey}.json`
|
|
51
|
+
const pageConfigRaw = await fetchFileContent(owner, repo, branch, pageConfigPath, githubToken)
|
|
52
|
+
if (!pageConfigRaw) return Response.json({ error: 'Page config not found' }, { status: 404 })
|
|
53
|
+
|
|
54
|
+
const pageConfig = JSON.parse(pageConfigRaw)
|
|
55
|
+
const existingKeys: string[] = (pageConfig.sections ?? []).map((s: { key: string }) => s.key)
|
|
56
|
+
|
|
57
|
+
// 2. Generate unique key
|
|
58
|
+
const newKey = generateAddKey(existingKeys, sectionType)
|
|
59
|
+
|
|
60
|
+
// 3. Build default content from schema (no GitHub write)
|
|
61
|
+
const sectionDef = findSectionDef(fullConfig, sectionType)
|
|
62
|
+
const defaultContent = sectionDef ? buildDefaultContent(sectionDef.fields ?? {}) : {}
|
|
63
|
+
|
|
64
|
+
// 4. Build updated page config (for client-side optimistic update)
|
|
65
|
+
const newEntry: Record<string, unknown> = {
|
|
66
|
+
key: newKey,
|
|
67
|
+
enabled: true,
|
|
68
|
+
order: pageConfig.sections.length,
|
|
69
|
+
...(newKey !== sectionType ? { type: sectionType } : {}),
|
|
70
|
+
}
|
|
71
|
+
const updatedPageConfig = {
|
|
72
|
+
...pageConfig,
|
|
73
|
+
sections: [...(pageConfig.sections ?? []), newEntry],
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return Response.json({
|
|
77
|
+
key: newKey,
|
|
78
|
+
type: sectionType,
|
|
79
|
+
defaultContent,
|
|
80
|
+
updatedPageConfig,
|
|
81
|
+
})
|
|
82
|
+
} catch (error) {
|
|
83
|
+
console.error('[setzkasten] section-prepare error:', error)
|
|
84
|
+
return Response.json(
|
|
85
|
+
{ error: error instanceof Error ? error.message : 'Prepare failed' },
|
|
86
|
+
{ status: 500 },
|
|
87
|
+
)
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// ---------------------------------------------------------------------------
|
|
92
|
+
// Helpers (same as section-add.ts)
|
|
93
|
+
// ---------------------------------------------------------------------------
|
|
94
|
+
|
|
95
|
+
function findSectionDef(fullConfig: any, sectionType: string): any {
|
|
96
|
+
if (!fullConfig?.products) return null
|
|
97
|
+
for (const product of Object.values(fullConfig.products) as any[]) {
|
|
98
|
+
if (product?.sections?.[sectionType]) return product.sections[sectionType]
|
|
99
|
+
}
|
|
100
|
+
return null
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function buildDefaultContent(fields: Record<string, any>): Record<string, unknown> {
|
|
104
|
+
const result: Record<string, unknown> = {}
|
|
105
|
+
for (const [key, field] of Object.entries(fields)) {
|
|
106
|
+
if (field.defaultValue !== undefined) result[key] = field.defaultValue
|
|
107
|
+
}
|
|
108
|
+
return result
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async function fetchFileContent(owner: string, repo: string, branch: string, path: string, token: string): Promise<string | null> {
|
|
112
|
+
try {
|
|
113
|
+
const res = await fetch(
|
|
114
|
+
`https://api.github.com/repos/${owner}/${repo}/contents/${path}?ref=${branch}`,
|
|
115
|
+
{ headers: { Authorization: `Bearer ${token}`, Accept: 'application/vnd.github+json', 'X-GitHub-Api-Version': '2022-11-28' } },
|
|
116
|
+
)
|
|
117
|
+
if (!res.ok) return null
|
|
118
|
+
const data = await res.json() as { content: string; encoding: string }
|
|
119
|
+
return data.encoding === 'base64' ? Buffer.from(data.content, 'base64').toString('utf-8') : data.content
|
|
120
|
+
} catch { return null }
|
|
121
|
+
}
|