@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
@@ -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
+ }
package/src/env.d.ts ADDED
@@ -0,0 +1,7 @@
1
+ interface ImportMeta {
2
+ readonly env: Record<string, string | undefined> & {
3
+ readonly PROD: boolean
4
+ readonly DEV: boolean
5
+ readonly MODE: string
6
+ }
7
+ }