@setzkasten-cms/astro 0.4.2

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.
@@ -0,0 +1,82 @@
1
+ import type { APIRoute } from 'astro'
2
+
3
+ /**
4
+ * Server-side proxy for GitHub API calls.
5
+ * The GitHub App token stays server-side (never exposed to browser).
6
+ *
7
+ * Client calls: POST /api/setzkasten/github/repos/{owner}/{repo}/...
8
+ * Proxy forwards to: https://api.github.com/repos/{owner}/{repo}/...
9
+ */
10
+ export const ALL: APIRoute = async ({ params, request, cookies }) => {
11
+ // Verify session
12
+ const session = cookies.get('setzkasten_session')?.value
13
+ if (!session) {
14
+ return new Response('Unauthorized', { status: 401 })
15
+ }
16
+
17
+ const githubPath = params.path
18
+ if (!githubPath) {
19
+ return new Response('Missing path', { status: 400 })
20
+ }
21
+
22
+ // GitHub App token from environment (never sent to client)
23
+ const githubToken = import.meta.env.GITHUB_TOKEN ?? process.env.GITHUB_TOKEN
24
+
25
+ if (!githubToken) {
26
+ return new Response('GitHub token not configured', { status: 500 })
27
+ }
28
+
29
+ const githubUrl = `https://api.github.com/${githubPath}`
30
+
31
+ try {
32
+ // Forward the request to GitHub API
33
+ const headers: Record<string, string> = {
34
+ Authorization: `Bearer ${githubToken}`,
35
+ Accept: 'application/vnd.github+json',
36
+ 'X-GitHub-Api-Version': '2022-11-28',
37
+ }
38
+
39
+ // Forward Content-Type for write operations
40
+ const contentType = request.headers.get('content-type')
41
+ if (contentType) {
42
+ headers['Content-Type'] = contentType
43
+ }
44
+
45
+ const body =
46
+ request.method !== 'GET' && request.method !== 'HEAD'
47
+ ? await request.text()
48
+ : undefined
49
+
50
+ const response = await fetch(githubUrl, {
51
+ method: request.method,
52
+ headers,
53
+ body,
54
+ })
55
+
56
+ // Forward response with rate limit headers
57
+ const responseHeaders = new Headers()
58
+ responseHeaders.set('Content-Type', response.headers.get('content-type') ?? 'application/json')
59
+
60
+ const rateLimitHeaders = [
61
+ 'x-ratelimit-limit',
62
+ 'x-ratelimit-remaining',
63
+ 'x-ratelimit-reset',
64
+ ]
65
+ for (const header of rateLimitHeaders) {
66
+ const value = response.headers.get(header)
67
+ if (value) responseHeaders.set(header, value)
68
+ }
69
+
70
+ // Forward ETag for caching
71
+ const etag = response.headers.get('etag')
72
+ if (etag) responseHeaders.set('etag', etag)
73
+
74
+ return new Response(await response.text(), {
75
+ status: response.status,
76
+ headers: responseHeaders,
77
+ })
78
+ } catch (error) {
79
+ console.error('[setzkasten] GitHub proxy error:', error)
80
+ return new Response('GitHub API request failed', { status: 502 })
81
+ }
82
+ }
@@ -0,0 +1,17 @@
1
+ import type { APIRoute } from 'astro'
2
+
3
+ /**
4
+ * Returns the list of pages detected at build time.
5
+ * The pages are scanned from src/pages/ by the integration hook
6
+ * and injected into globalThis.__SETZKASTEN_PAGES__.
7
+ *
8
+ * GET /api/setzkasten/pages
9
+ */
10
+ export const GET: APIRoute = async () => {
11
+ const pages = (globalThis as Record<string, unknown>).__SETZKASTEN_PAGES__ ?? []
12
+
13
+ return new Response(JSON.stringify({ pages }), {
14
+ status: 200,
15
+ headers: { 'Content-Type': 'application/json' },
16
+ })
17
+ }
@@ -0,0 +1,61 @@
1
+ // ---------------------------------------------------------------------------
2
+ // In-memory draft store for Live SSR Preview
3
+ // Stores draft section content keyed by session ID.
4
+ // Used by the preview middleware to inject draft data into getSection().
5
+ // ---------------------------------------------------------------------------
6
+
7
+ interface DraftEntry {
8
+ values: Record<string, unknown>
9
+ updatedAt: number
10
+ }
11
+
12
+ type SessionDrafts = Map<string, DraftEntry>
13
+
14
+ const store = new Map<string, SessionDrafts>()
15
+
16
+ // Auto-cleanup stale sessions after 30 minutes
17
+ const MAX_AGE_MS = 30 * 60 * 1000
18
+
19
+ export function setDraft(
20
+ sessionId: string,
21
+ sectionKey: string,
22
+ values: Record<string, unknown>,
23
+ ): void {
24
+ if (!store.has(sessionId)) store.set(sessionId, new Map())
25
+ store.get(sessionId)!.set(sectionKey, { values, updatedAt: Date.now() })
26
+ cleanup()
27
+ }
28
+
29
+ export function getDrafts(
30
+ sessionId: string,
31
+ ): Record<string, Record<string, unknown>> | null {
32
+ const drafts = store.get(sessionId)
33
+ if (!drafts || drafts.size === 0) return null
34
+ const result: Record<string, Record<string, unknown>> = {}
35
+ for (const [key, entry] of drafts) {
36
+ result[key] = entry.values
37
+ }
38
+ return result
39
+ }
40
+
41
+ export function clearDraft(sessionId: string, sectionKey?: string): void {
42
+ if (sectionKey) {
43
+ store.get(sessionId)?.delete(sectionKey)
44
+ } else {
45
+ store.delete(sessionId)
46
+ }
47
+ }
48
+
49
+ /** Remove sessions that haven't been updated in MAX_AGE_MS */
50
+ function cleanup(): void {
51
+ const now = Date.now()
52
+ for (const [sessionId, drafts] of store) {
53
+ let newest = 0
54
+ for (const entry of drafts.values()) {
55
+ if (entry.updatedAt > newest) newest = entry.updatedAt
56
+ }
57
+ if (now - newest > MAX_AGE_MS) {
58
+ store.delete(sessionId)
59
+ }
60
+ }
61
+ }
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
+ }
package/src/index.ts ADDED
@@ -0,0 +1,11 @@
1
+ /**
2
+ * @setzkasten-cms/astro – Astro Integration
3
+ *
4
+ * Composition root that wires all adapters together:
5
+ * - Astro integration hook to mount the admin SPA under /admin
6
+ * - API routes for auth callbacks and GitHub proxy
7
+ * - Preview middleware for live SSR preview (draft-aware rendering)
8
+ */
9
+
10
+ export { setzkasten as default, setzkasten } from './integration'
11
+ export type { SetzKastenIntegrationOptions } from './integration'
@@ -0,0 +1,378 @@
1
+ import { resolve, dirname, join } from 'node:path'
2
+ import { existsSync, readFileSync, readdirSync } from 'node:fs'
3
+ import type { AstroIntegration } from 'astro'
4
+ import type { SetzKastenConfig } from '@setzkasten-cms/core'
5
+
6
+ export interface SetzKastenIntegrationOptions {
7
+ /** Base path for the admin UI (default: '/admin') */
8
+ adminPath?: string
9
+ /** Full CMS config (products, sections, collections, theme) */
10
+ config?: SetzKastenConfig
11
+ /** GitHub repository for content storage */
12
+ storage?: {
13
+ owner: string
14
+ repo: string
15
+ branch?: string
16
+ contentPath?: string
17
+ /** Path to public/images in the repo (default: 'public/images'). For monorepos e.g. 'apps/website/public/images' */
18
+ assetsPath?: string
19
+ }
20
+ /** GitHub OAuth config */
21
+ github?: {
22
+ clientId: string
23
+ clientSecret: string
24
+ }
25
+ /** Google OAuth config */
26
+ google?: {
27
+ clientId: string
28
+ clientSecret: string
29
+ }
30
+ /** Allowed email addresses for login */
31
+ allowedEmails?: string[]
32
+ }
33
+
34
+ /**
35
+ * Astro integration that mounts the Setzkasten admin SPA.
36
+ *
37
+ * Usage in astro.config.mjs:
38
+ * ```js
39
+ * import setzkasten from '@setzkasten-cms/astro'
40
+ * export default defineConfig({
41
+ * integrations: [setzkasten({ adminPath: '/admin' })],
42
+ * })
43
+ * ```
44
+ */
45
+ export function setzkasten(
46
+ options: SetzKastenIntegrationOptions = {},
47
+ ): AstroIntegration {
48
+ const adminPath = options.adminPath ?? '/admin'
49
+
50
+ return {
51
+ name: 'setzkasten',
52
+ hooks: {
53
+ 'astro:config:setup': ({ injectRoute, injectScript, updateConfig, config, addMiddleware }) => {
54
+ const contentPath = options.storage?.contentPath ?? 'content'
55
+ const projectRoot = config.root?.pathname ?? process.cwd()
56
+ // Resolve content dir relative to git/monorepo root (not Astro project root)
57
+ const repoRoot = findRepoRoot(projectRoot)
58
+ const contentDir = resolve(repoRoot, contentPath)
59
+
60
+ // Virtual module: setzkasten:content
61
+ updateConfig({
62
+ vite: {
63
+ plugins: [virtualContentPlugin(contentDir) as any],
64
+ },
65
+ })
66
+ // Mount the admin SPA catch-all route
67
+ injectRoute({
68
+ pattern: `${adminPath}/[...path]`,
69
+ entrypoint: '@setzkasten-cms/astro/admin-page',
70
+ prerender: false,
71
+ })
72
+
73
+ // Auth routes
74
+ injectRoute({
75
+ pattern: '/api/setzkasten/auth/login',
76
+ entrypoint: '@setzkasten-cms/astro/auth-login',
77
+ prerender: false,
78
+ })
79
+
80
+ injectRoute({
81
+ pattern: '/api/setzkasten/auth/callback',
82
+ entrypoint: '@setzkasten-cms/astro/auth-callback',
83
+ prerender: false,
84
+ })
85
+
86
+ injectRoute({
87
+ pattern: '/api/setzkasten/auth/logout',
88
+ entrypoint: '@setzkasten-cms/astro/auth-logout',
89
+ prerender: false,
90
+ })
91
+
92
+ injectRoute({
93
+ pattern: '/api/setzkasten/auth/session',
94
+ entrypoint: '@setzkasten-cms/astro/auth-session',
95
+ prerender: false,
96
+ })
97
+
98
+ // GitHub proxy API route
99
+ injectRoute({
100
+ pattern: '/api/setzkasten/github/[...path]',
101
+ entrypoint: '@setzkasten-cms/astro/github-proxy',
102
+ prerender: false,
103
+ })
104
+
105
+ // Asset proxy (serves images from private GitHub repo)
106
+ injectRoute({
107
+ pattern: '/api/setzkasten/asset/[...path]',
108
+ entrypoint: '@setzkasten-cms/astro/asset-proxy',
109
+ prerender: false,
110
+ })
111
+
112
+ // Config API route
113
+ injectRoute({
114
+ pattern: '/api/setzkasten/config',
115
+ entrypoint: '@setzkasten-cms/astro/config',
116
+ prerender: false,
117
+ })
118
+
119
+ // Draft API route (for Live SSR Preview)
120
+ injectRoute({
121
+ pattern: '/api/setzkasten/draft',
122
+ entrypoint: '@setzkasten-cms/astro/draft',
123
+ prerender: false,
124
+ })
125
+
126
+ // Pages API route (returns detected pages)
127
+ injectRoute({
128
+ pattern: '/api/setzkasten/pages',
129
+ entrypoint: '@setzkasten-cms/astro/pages',
130
+ prerender: false,
131
+ })
132
+
133
+ // Preview middleware (intercepts ?_sk_preview for draft-aware SSR)
134
+ addMiddleware({
135
+ entrypoint: '@setzkasten-cms/astro/preview-middleware',
136
+ order: 'pre',
137
+ })
138
+
139
+ // Inject minimal config for SSR pages (auth detection + storage)
140
+ injectScript(
141
+ 'page-ssr',
142
+ `globalThis.__SETZKASTEN_CONFIG__ = ${JSON.stringify({
143
+ adminPath,
144
+ hasGitHub: !!options.github,
145
+ hasGoogle: !!options.google,
146
+ storage: options.storage ? {
147
+ owner: options.storage.owner,
148
+ repo: options.storage.repo,
149
+ branch: options.storage.branch ?? 'main',
150
+ contentPath: options.storage.contentPath ?? 'content',
151
+ assetsPath: options.storage.assetsPath ?? 'public/images',
152
+ } : undefined,
153
+ })}`,
154
+ )
155
+
156
+ // Scan available pages and inject for the pages API
157
+ const pagesDir = resolve(projectRoot, 'src/pages')
158
+ const detectedPages = scanPages(pagesDir, contentDir, adminPath)
159
+ injectScript(
160
+ 'page-ssr',
161
+ `globalThis.__SETZKASTEN_PAGES__ = ${JSON.stringify(detectedPages)}`,
162
+ )
163
+
164
+ // Inject full CMS config for the config API route
165
+ if (options.config) {
166
+ injectScript(
167
+ 'page-ssr',
168
+ `globalThis.__SETZKASTEN_FULL_CONFIG__ = ${JSON.stringify(options.config)}`,
169
+ )
170
+ }
171
+ },
172
+
173
+ 'astro:config:done': ({ config }) => {
174
+ // Verify an adapter is configured (needed for SSR routes)
175
+ if (!config.adapter) {
176
+ console.warn(
177
+ '[setzkasten] Warning: No adapter detected. Setzkasten requires an SSR adapter (e.g. @astrojs/vercel) for API routes.',
178
+ )
179
+ }
180
+ },
181
+ },
182
+ }
183
+ }
184
+
185
+ // ---------------------------------------------------------------------------
186
+ // Helpers
187
+ // ---------------------------------------------------------------------------
188
+
189
+ interface PageInfo {
190
+ /** URL path, e.g. '/' or '/docs' or '/docs/architecture' */
191
+ path: string
192
+ /** Page key for config file lookup, e.g. 'index' or 'docs' or 'docs/architecture' */
193
+ pageKey: string
194
+ /** Human-readable label */
195
+ label: string
196
+ /** Whether a page config file exists (content/pages/_<key>.json) */
197
+ hasConfig: boolean
198
+ }
199
+
200
+ function scanPages(pagesDir: string, contentDir: string, adminPath: string): PageInfo[] {
201
+ if (!existsSync(pagesDir)) return []
202
+
203
+ const pages: PageInfo[] = []
204
+
205
+ function walk(dir: string, prefix: string) {
206
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
207
+ if (entry.isDirectory()) {
208
+ // Skip admin and api directories
209
+ const dirName = entry.name
210
+ if (dirName === 'api' || `/${prefix}${dirName}`.startsWith(adminPath)) continue
211
+ walk(join(dir, dirName), `${prefix}${dirName}/`)
212
+ } else if (entry.name.endsWith('.astro')) {
213
+ const name = entry.name.replace(/\.astro$/, '')
214
+
215
+ // Skip catch-all routes, preview page
216
+ if (name.includes('[') || name === 'preview') continue
217
+
218
+ // Convert to URL path and page key
219
+ const isIndex = name === 'index'
220
+ const urlPath = isIndex
221
+ ? prefix ? `/${prefix.replace(/\/$/, '')}` : '/'
222
+ : `/${prefix}${name}`
223
+ const pageKey = isIndex
224
+ ? prefix ? prefix.replace(/\/$/, '') : 'index'
225
+ : `${prefix}${name}`
226
+
227
+ // Check if page config exists
228
+ const configKey = '_' + pageKey.replace(/\//g, '--')
229
+ const hasConfig = existsSync(join(contentDir, 'pages', `${configKey}.json`))
230
+
231
+ pages.push({
232
+ path: urlPath,
233
+ pageKey,
234
+ label: pageKey === 'index' ? 'Startseite' : pageKey,
235
+ hasConfig,
236
+ })
237
+ }
238
+ }
239
+ }
240
+
241
+ walk(pagesDir, '')
242
+ // Sort: index first, then alphabetically
243
+ pages.sort((a, b) => {
244
+ if (a.pageKey === 'index') return -1
245
+ if (b.pageKey === 'index') return 1
246
+ return a.pageKey.localeCompare(b.pageKey)
247
+ })
248
+
249
+ return pages
250
+ }
251
+
252
+ function findRepoRoot(startDir: string): string {
253
+ let dir = startDir
254
+ for (let i = 0; i < 10; i++) {
255
+ if (existsSync(join(dir, '.git')) || existsSync(join(dir, 'pnpm-workspace.yaml'))) {
256
+ return dir
257
+ }
258
+ const parent = dirname(dir)
259
+ if (parent === dir) break
260
+ dir = parent
261
+ }
262
+ return startDir // fallback: use project root
263
+ }
264
+
265
+ // ---------------------------------------------------------------------------
266
+ // Virtual Module: setzkasten:content
267
+ // ---------------------------------------------------------------------------
268
+
269
+ const VIRTUAL_ID = 'setzkasten:content'
270
+ const RESOLVED_ID = '\0' + VIRTUAL_ID
271
+
272
+ function virtualContentPlugin(contentDir: string) {
273
+ // Read all content at build time and embed as static data.
274
+ // This avoids readFileSync at runtime which fails on Vercel serverless.
275
+ function readJsonDir(dir: string): Record<string, unknown> {
276
+ const result: Record<string, unknown> = {}
277
+ if (!existsSync(dir)) return result
278
+ for (const f of readdirSync(dir)) {
279
+ if (!f.endsWith('.json')) continue
280
+ try {
281
+ result[f.replace(/\.json$/, '')] = JSON.parse(readFileSync(join(dir, f), 'utf-8'))
282
+ } catch { /* skip invalid JSON */ }
283
+ }
284
+ return result
285
+ }
286
+
287
+ // Scan all collection directories (skip _sections and pages which are handled separately)
288
+ function readCollections(baseDir: string): Record<string, unknown[]> {
289
+ const result: Record<string, unknown[]> = {}
290
+ if (!existsSync(baseDir)) return result
291
+ for (const entry of readdirSync(baseDir, { withFileTypes: true })) {
292
+ if (!entry.isDirectory() || entry.name === '_sections' || entry.name === 'pages') continue
293
+ const items: unknown[] = []
294
+ const dir = join(baseDir, entry.name)
295
+ for (const f of readdirSync(dir)) {
296
+ if (!f.endsWith('.json')) continue
297
+ try {
298
+ items.push({
299
+ slug: f.replace(/\.json$/, ''),
300
+ ...JSON.parse(readFileSync(join(dir, f), 'utf-8')) as Record<string, unknown>,
301
+ })
302
+ } catch { /* skip */ }
303
+ }
304
+ result[entry.name] = items
305
+ }
306
+ return result
307
+ }
308
+
309
+ return {
310
+ name: 'setzkasten:content',
311
+ resolveId(id: string) {
312
+ if (id === VIRTUAL_ID) return RESOLVED_ID
313
+ },
314
+ load(id: string) {
315
+ if (id !== RESOLVED_ID) return
316
+
317
+ // Embed content data at build time
318
+ const sections = readJsonDir(join(contentDir, '_sections'))
319
+ const pages = readJsonDir(join(contentDir, 'pages'))
320
+ const collections = readCollections(contentDir)
321
+
322
+ return `
323
+ // Content data embedded at build time from: ${contentDir}
324
+ const SECTIONS = ${JSON.stringify(sections)};
325
+ const PAGES = ${JSON.stringify(pages)};
326
+ const COLLECTIONS = ${JSON.stringify(collections)};
327
+
328
+ // ---------------------------------------------------------------------------
329
+ // Draft-aware getSection: checks AsyncLocalStorage for preview drafts first.
330
+ // The ALS is populated by the preview middleware for ?_sk_preview requests.
331
+ // ---------------------------------------------------------------------------
332
+
333
+ function _getDraft(key) {
334
+ try {
335
+ var als = globalThis[Symbol.for('setzkasten.preview.als')]
336
+ var drafts = als?.getStore?.()
337
+ return drafts?.[key] ?? null
338
+ } catch {
339
+ return null
340
+ }
341
+ }
342
+
343
+ export function getSection(key) {
344
+ var draft = _getDraft(key)
345
+ if (draft) return draft
346
+ return SECTIONS[key] ? JSON.parse(JSON.stringify(SECTIONS[key])) : null
347
+ }
348
+
349
+ export function getCollection(key) {
350
+ return COLLECTIONS[key] ? JSON.parse(JSON.stringify(COLLECTIONS[key])) : []
351
+ }
352
+
353
+ export function getCollectionEntry(collection, slug) {
354
+ var items = COLLECTIONS[collection]
355
+ if (!items) return null
356
+ var entry = items.find(function(e) { return e.slug === slug })
357
+ return entry ? JSON.parse(JSON.stringify(entry)) : null
358
+ }
359
+
360
+ // ---------------------------------------------------------------------------
361
+ // Page configuration: section ordering, enable/disable
362
+ // ---------------------------------------------------------------------------
363
+
364
+ export function getPage(pageKey, opts) {
365
+ pageKey = pageKey || 'index'
366
+ var config = PAGES['_' + pageKey]
367
+ if (!config) return null
368
+ config = JSON.parse(JSON.stringify(config))
369
+ var includeDisabled = opts && opts.includeDisabled
370
+ var sections = (config.sections || [])
371
+ .filter(function(s) { return includeDisabled || s.enabled !== false })
372
+ .map(function(s) { return { key: s.key, data: getSection(s.key), enabled: s.enabled !== false } })
373
+ return { sections: sections, config: config }
374
+ }
375
+ `
376
+ },
377
+ }
378
+ }