@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,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
|
+
}
|