@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,92 @@
1
+ /**
2
+ * Shared types between section analyzer, label enricher, and template patcher.
3
+ * This is the contract that ensures clean separation of concerns:
4
+ * Analyzer (structure) → Enricher (labels) → Patcher (template edits)
5
+ */
6
+
7
+ // ---------------------------------------------------------------------------
8
+ // Inner field info (fields inside repeated element groups)
9
+ // ---------------------------------------------------------------------------
10
+
11
+ export interface FieldPosition {
12
+ /** Char offset in the template source where the field value starts */
13
+ offset: number
14
+ /** Length of the field value to replace */
15
+ length: number
16
+ /** For .map() arrays: name of the frontmatter variable (e.g. 'starterFeatures') */
17
+ source?: string
18
+ }
19
+
20
+ export interface InnerFieldInfo {
21
+ /** Generic structural key: heading, text1, text2, list1, link1, linkText1 */
22
+ key: string
23
+ /** Field type */
24
+ type: 'text' | 'array' | 'link'
25
+ /** HTML tag this field was extracted from */
26
+ tag: string
27
+ /** Present in ALL instances? (false = optional, only in some) */
28
+ required: boolean
29
+ /** Cosmetic label (set by enricher, not analyzer) */
30
+ label?: string
31
+ /** Position of this field's value in EACH instance (null = absent in that instance) */
32
+ positions: Array<FieldPosition | null>
33
+ /** Default value per instance (null if field absent in that instance) */
34
+ defaultValues: unknown[]
35
+ }
36
+
37
+ // ---------------------------------------------------------------------------
38
+ // Repeated element group (e.g. 3× <article>)
39
+ // ---------------------------------------------------------------------------
40
+
41
+ export interface RepeatedGroupInstance {
42
+ /** Source offset of opening tag */
43
+ start: number
44
+ /** Source offset of closing tag end */
45
+ end: number
46
+ }
47
+
48
+ /** A class attribute on an element within a repeated group instance */
49
+ export interface ClassAttrInfo {
50
+ /** Child-index path within the instance element (e.g. "0/1/3") for matching across instances */
51
+ path: string
52
+ /** The full class attribute value */
53
+ value: string
54
+ /** Source offset of `class="` start in the full source */
55
+ sourceOffset: number
56
+ /** Length of the full `class="..."` attribute string in source */
57
+ sourceLength: number
58
+ }
59
+
60
+ export interface RepeatedGroup {
61
+ /** HTML tag name (e.g. 'article') */
62
+ tag: string
63
+ /** Field key for the array (e.g. 'items') */
64
+ fieldKey: string
65
+ /** Source bounds of each instance */
66
+ instances: RepeatedGroupInstance[]
67
+ /** Which instance to use as .map() template (usually 0) */
68
+ templateIndex: number
69
+ /** All detected inner fields */
70
+ fields: InnerFieldInfo[]
71
+ /** Class attributes per instance, matched by structural path. classAttrs[i] = attrs for instance i */
72
+ classAttrs?: ClassAttrInfo[][]
73
+ }
74
+
75
+ // ---------------------------------------------------------------------------
76
+ // Analyzer result (consumed by enricher + patcher)
77
+ // ---------------------------------------------------------------------------
78
+
79
+ export interface AnalyzerResult {
80
+ sectionKey: string
81
+ componentName: string
82
+ componentPath: string
83
+ alreadyIntegrated: boolean
84
+ /** All top-level fields (text, arrays, etc.) */
85
+ fields: import('@setzkasten-cms/core/init').InferredField[]
86
+ /** Repeated element groups with positions for the patcher */
87
+ repeatedGroups: RepeatedGroup[]
88
+ /** Raw frontmatter block */
89
+ frontmatter: string
90
+ /** Raw template block */
91
+ template: string
92
+ }
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Patches an existing astro.config.* file to add the Setzkasten integration.
3
+ * Uses string manipulation — no AST parser.
4
+ */
5
+
6
+ /**
7
+ * Add the Setzkasten integration import and usage to an Astro config file.
8
+ * Returns the patched content, or null if already integrated.
9
+ */
10
+ export function patchAstroConfig(source: string): string | null {
11
+ // Check if already integrated
12
+ if (source.includes('@setzkasten-cms/astro') || source.includes('setzkasten(')) {
13
+ return null
14
+ }
15
+
16
+ let result = source
17
+
18
+ // Add import at the top (after existing imports)
19
+ const importLine = "import setzkasten from '@setzkasten-cms/astro'\n"
20
+
21
+ // Find the last import statement
22
+ const lastImportIndex = findLastImportEnd(result)
23
+ if (lastImportIndex >= 0) {
24
+ result =
25
+ result.slice(0, lastImportIndex) +
26
+ '\n' +
27
+ importLine +
28
+ result.slice(lastImportIndex)
29
+ } else {
30
+ // No imports found — add at the very top
31
+ result = importLine + '\n' + result
32
+ }
33
+
34
+ // Add setzkasten() to the integrations array
35
+ result = addToIntegrations(result)
36
+
37
+ return result
38
+ }
39
+
40
+ /**
41
+ * Find the end position of the last import statement.
42
+ */
43
+ function findLastImportEnd(source: string): number {
44
+ const lines = source.split('\n')
45
+ let lastImportLineEnd = -1
46
+ let pos = 0
47
+
48
+ for (const line of lines) {
49
+ pos += line.length + 1 // +1 for newline
50
+ if (line.trimStart().startsWith('import ')) {
51
+ lastImportLineEnd = pos
52
+ }
53
+ }
54
+
55
+ return lastImportLineEnd
56
+ }
57
+
58
+ /**
59
+ * Add setzkasten() to the integrations array in defineConfig.
60
+ */
61
+ function addToIntegrations(source: string): string {
62
+ // Case 1: integrations array exists — add setzkasten() to it
63
+ const integrationsMatch = source.match(/integrations\s*:\s*\[/)
64
+ if (integrationsMatch && integrationsMatch.index !== undefined) {
65
+ const insertPos = integrationsMatch.index + integrationsMatch[0].length
66
+ const after = source.slice(insertPos).trimStart()
67
+
68
+ // Check if array is empty
69
+ if (after.startsWith(']')) {
70
+ return (
71
+ source.slice(0, insertPos) +
72
+ 'setzkasten()' +
73
+ source.slice(insertPos)
74
+ )
75
+ }
76
+
77
+ // Array has items — prepend
78
+ return (
79
+ source.slice(0, insertPos) +
80
+ '\n setzkasten(),\n ' +
81
+ source.slice(insertPos)
82
+ )
83
+ }
84
+
85
+ // Case 2: No integrations array — add it to defineConfig
86
+ const defineConfigMatch = source.match(/defineConfig\s*\(\s*\{/)
87
+ if (defineConfigMatch && defineConfigMatch.index !== undefined) {
88
+ const insertPos = defineConfigMatch.index + defineConfigMatch[0].length
89
+ return (
90
+ source.slice(0, insertPos) +
91
+ '\n integrations: [setzkasten()],\n' +
92
+ source.slice(insertPos)
93
+ )
94
+ }
95
+
96
+ // Case 3: No defineConfig — can't patch automatically
97
+ return source
98
+ }
@@ -0,0 +1,207 @@
1
+ /**
2
+ * Astro-specific project detection.
3
+ * Finds pages, section imports, and component files in a repo's file tree.
4
+ */
5
+
6
+ import type { RepoFile } from '@setzkasten-cms/core/init'
7
+
8
+ export interface AstroPage {
9
+ /** File path relative to repo root, e.g. 'apps/website/src/pages/index.astro' */
10
+ filePath: string
11
+ /** Page key, e.g. 'index' or 'about' */
12
+ pageKey: string
13
+ /** Human-readable label */
14
+ label: string
15
+ }
16
+
17
+ export interface AstroSectionImport {
18
+ /** Component name, e.g. 'HeroSection' */
19
+ componentName: string
20
+ /** Import path as written in the source, e.g. '../components/sections/HeroSection.astro' */
21
+ importPath: string
22
+ /** Resolved file path relative to repo root */
23
+ resolvedPath: string | null
24
+ /** Derived section key, e.g. 'hero' */
25
+ sectionKey: string
26
+ }
27
+
28
+ /**
29
+ * Find all Astro page files in a project root.
30
+ */
31
+ export function findAstroPages(files: RepoFile[], projectRoot: string): AstroPage[] {
32
+ const pagesDir = projectRoot ? `${projectRoot}/src/pages/` : 'src/pages/'
33
+ const pages: AstroPage[] = []
34
+
35
+ for (const file of files) {
36
+ if (file.type !== 'blob') continue
37
+ if (!file.path.startsWith(pagesDir)) continue
38
+ if (!file.path.endsWith('.astro')) continue
39
+
40
+ const relativePath = file.path.slice(pagesDir.length)
41
+
42
+ // Skip API routes, layouts, catch-all routes, admin pages
43
+ if (relativePath.startsWith('api/')) continue
44
+ if (relativePath.includes('[')) continue
45
+ if (relativePath.includes('admin')) continue
46
+ if (relativePath === 'preview.astro') continue
47
+
48
+ const name = relativePath.replace(/\.astro$/, '')
49
+ const isIndex = name === 'index' || name.endsWith('/index')
50
+ const pageKey = isIndex
51
+ ? name === 'index' ? 'index' : name.replace(/\/index$/, '')
52
+ : name
53
+
54
+ pages.push({
55
+ filePath: file.path,
56
+ pageKey,
57
+ label: pageKey === 'index' ? 'Startseite' : pageKey,
58
+ })
59
+ }
60
+
61
+ // Sort: index first, then alphabetically
62
+ pages.sort((a, b) => {
63
+ if (a.pageKey === 'index') return -1
64
+ if (b.pageKey === 'index') return 1
65
+ return a.pageKey.localeCompare(b.pageKey)
66
+ })
67
+
68
+ return pages
69
+ }
70
+
71
+ /**
72
+ * Extract section imports from an Astro page's source code.
73
+ */
74
+ export function extractSectionImports(
75
+ pageSource: string,
76
+ pagePath: string,
77
+ allFiles: RepoFile[],
78
+ projectRoot: string,
79
+ ): AstroSectionImport[] {
80
+ const imports: AstroSectionImport[] = []
81
+
82
+ // Match: import ComponentName from '...' or import ComponentName from "..."
83
+ const importRegex = /import\s+(\w+)\s+from\s+['"]([^'"]+)['"]/g
84
+ let match: RegExpExecArray | null
85
+
86
+ while ((match = importRegex.exec(pageSource)) !== null) {
87
+ const componentName = match[1]!
88
+ const importPath = match[2]!
89
+
90
+ // Only consider explicit CMS section components:
91
+ // - imported from a path containing 'section', OR
92
+ // - component name ends with 'Section'
93
+ // This excludes generic UI components like CodeBlock, Nav, Button, etc.
94
+ const isSectionPath = importPath.includes('section')
95
+ const isSectionName = componentName.endsWith('Section')
96
+ if (!isSectionPath && !isSectionName) continue
97
+ // Skip non-component imports
98
+ if (!importPath.endsWith('.astro') && !importPath.match(/\/[A-Z]/)) continue
99
+
100
+ const sectionKey = deriveSectionKey(componentName)
101
+ const resolvedPath = resolveImportPath(importPath, pagePath, allFiles, projectRoot)
102
+
103
+ imports.push({
104
+ componentName,
105
+ importPath,
106
+ resolvedPath,
107
+ sectionKey,
108
+ })
109
+ }
110
+
111
+ return imports
112
+ }
113
+
114
+ export interface AstroLayoutImport {
115
+ /** Component name, e.g. 'BaseLayout' */
116
+ componentName: string
117
+ /** Import path as written in the source */
118
+ importPath: string
119
+ /** Resolved file path relative to repo root */
120
+ resolvedPath: string | null
121
+ }
122
+
123
+ /**
124
+ * Extract the layout import from an Astro page's source code.
125
+ * Returns the first import whose path contains 'layout' or whose name contains 'Layout'.
126
+ */
127
+ export function extractLayoutImport(
128
+ pageSource: string,
129
+ pagePath: string,
130
+ allFiles: RepoFile[],
131
+ projectRoot: string,
132
+ ): AstroLayoutImport | null {
133
+ const importRegex = /import\s+(\w+)\s+from\s+['"]([^'"]+)['"]/g
134
+ let match: RegExpExecArray | null
135
+
136
+ while ((match = importRegex.exec(pageSource)) !== null) {
137
+ const componentName = match[1]!
138
+ const importPath = match[2]!
139
+
140
+ if (!importPath.includes('layout') && !componentName.includes('Layout')) continue
141
+ if (!importPath.endsWith('.astro') && !importPath.match(/\/[A-Z]/)) continue
142
+
143
+ const resolvedPath = resolveImportPath(importPath, pagePath, allFiles, projectRoot)
144
+ return { componentName, importPath, resolvedPath }
145
+ }
146
+
147
+ return null
148
+ }
149
+
150
+ /**
151
+ * Derive a section key from a component name.
152
+ * HeroSection → hero, HowItWorksSection → howItWorks, CtaSection → cta,
153
+ * SchemaShowcaseSection → schemaShowcase
154
+ * Uses camelCase (valid JS identifier) to avoid quoted keys in config.
155
+ */
156
+ export function deriveSectionKey(componentName: string): string {
157
+ const stripped = componentName.replace(/Section$/, '')
158
+ return stripped.charAt(0).toLowerCase() + stripped.slice(1)
159
+ }
160
+
161
+ /**
162
+ * Resolve a relative import path to an absolute repo path.
163
+ */
164
+ function resolveImportPath(
165
+ importPath: string,
166
+ fromFile: string,
167
+ allFiles: RepoFile[],
168
+ projectRoot: string,
169
+ ): string | null {
170
+ // Handle alias imports like @/components/... or ~/components/...
171
+ if (importPath.startsWith('@/') || importPath.startsWith('~/')) {
172
+ const aliasPath = importPath.replace(/^[@~]\//, '')
173
+ const candidates = [
174
+ `${projectRoot}/src/${aliasPath}`,
175
+ `${projectRoot}/src/${aliasPath}.astro`,
176
+ ]
177
+ for (const candidate of candidates) {
178
+ if (allFiles.some((f) => f.path === candidate)) return candidate
179
+ }
180
+ return null
181
+ }
182
+
183
+ // Handle relative imports
184
+ if (importPath.startsWith('.')) {
185
+ const fromDir = fromFile.substring(0, fromFile.lastIndexOf('/'))
186
+ const parts = importPath.split('/')
187
+ let currentDir = fromDir
188
+
189
+ for (const part of parts) {
190
+ if (part === '.') continue
191
+ if (part === '..') {
192
+ currentDir = currentDir.substring(0, currentDir.lastIndexOf('/'))
193
+ } else {
194
+ currentDir = `${currentDir}/${part}`
195
+ }
196
+ }
197
+
198
+ const resolved = currentDir
199
+ // Try with and without .astro extension
200
+ const candidates = [resolved, `${resolved}.astro`]
201
+ for (const candidate of candidates) {
202
+ if (allFiles.some((f) => f.path === candidate)) return candidate
203
+ }
204
+ }
205
+
206
+ return null
207
+ }