@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.
- package/LICENSE +37 -0
- package/package.json +33 -0
- package/src/admin-page.astro +146 -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/config.ts +30 -0
- package/src/api-routes/draft.ts +61 -0
- package/src/api-routes/github-proxy.ts +82 -0
- package/src/api-routes/pages.ts +17 -0
- package/src/draft-store.ts +61 -0
- package/src/env.d.ts +7 -0
- package/src/index.ts +11 -0
- package/src/integration.ts +378 -0
- package/src/preview-middleware.ts +184 -0
- package/tsconfig.json +9 -0
|
@@ -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
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
|
+
}
|