@setzkasten-cms/astro-admin 0.6.0 → 0.8.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/package.json +13 -6
- package/src/admin-page.astro +8 -7
- package/src/api-routes/__tests__/commit-trailers.test.ts +69 -0
- package/src/api-routes/__tests__/github-cache.test.ts +100 -0
- package/src/api-routes/__tests__/pages.test.ts +72 -0
- package/src/api-routes/_auth-guard.ts +32 -0
- package/src/api-routes/_commit-trailers.ts +16 -0
- package/src/api-routes/_github-cache.ts +32 -0
- package/src/api-routes/auth-callback.ts +17 -48
- package/src/api-routes/auth-login.ts +18 -65
- package/src/api-routes/auth-setzkasten-login.ts +60 -0
- package/src/api-routes/catalog-add.ts +10 -1
- package/src/api-routes/config.ts +5 -0
- package/src/api-routes/editors.ts +136 -0
- package/src/api-routes/global-config.ts +132 -0
- package/src/api-routes/init-add-section.ts +8 -5
- package/src/api-routes/init-apply.ts +2 -1
- package/src/api-routes/init-migrate.ts +2 -1
- package/src/api-routes/pages.ts +23 -5
- package/src/api-routes/section-add.ts +9 -1
- package/src/api-routes/section-commit-pending.ts +11 -1
- package/src/api-routes/section-delete.ts +10 -1
- package/src/api-routes/section-duplicate.ts +11 -1
- package/src/api-routes/section-prepare.ts +4 -0
- package/src/api-routes/updater-check.ts +49 -0
- package/src/api-routes/updater-register.ts +107 -0
- package/src/api-routes/updater-transfer.ts +62 -0
- package/src/api-routes/updater-unbind.ts +59 -0
- package/src/init/__tests__/page-level.test.ts +47 -0
- package/src/init/__tests__/section-pipeline.test.ts +3 -1
- package/src/init/astro-section-analyzer-v2.ts +29 -2
- package/src/init/template-patcher-v2.ts +67 -0
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { APIRoute } from 'astro'
|
|
2
2
|
import { resolveStorageConfig, prefixPath } from './_storage-config'
|
|
3
3
|
import { generateAddKey } from './section-management'
|
|
4
|
+
import { parseSession, guardPageAccess } from './_auth-guard'
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* POST /api/setzkasten/sections/prepare
|
|
@@ -45,6 +46,9 @@ export const POST: APIRoute = async ({ request, cookies }) => {
|
|
|
45
46
|
return Response.json({ error: 'pageKey and sectionType are required' }, { status: 400 })
|
|
46
47
|
}
|
|
47
48
|
|
|
49
|
+
const denied = guardPageAccess(parseSession(cookies.get('setzkasten_session')?.value), pageKey, fullConfig)
|
|
50
|
+
if (denied) return denied
|
|
51
|
+
|
|
48
52
|
// 1. Read current page config from GitHub to determine existing keys
|
|
49
53
|
const configKey = '_' + pageKey.replace(/\//g, '_')
|
|
50
54
|
const pageConfigPath = `${contentPath}/pages/${configKey}.json`
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type { APIRoute } from 'astro'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Lightweight update check without re-registration.
|
|
5
|
+
*
|
|
6
|
+
* GET /api/setzkasten/updater/check?instanceId=X
|
|
7
|
+
*/
|
|
8
|
+
export const GET: APIRoute = async ({ cookies, url }) => {
|
|
9
|
+
const session = cookies.get('setzkasten_session')?.value
|
|
10
|
+
if (!session) {
|
|
11
|
+
return new Response('Unauthorized', { status: 401 })
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const config = (globalThis as any).__SETZKASTEN_CONFIG__ as {
|
|
15
|
+
updaterUrl?: string
|
|
16
|
+
version?: string
|
|
17
|
+
} | undefined
|
|
18
|
+
|
|
19
|
+
const updaterUrl = config?.updaterUrl
|
|
20
|
+
if (!updaterUrl) {
|
|
21
|
+
return Response.json({
|
|
22
|
+
updateAvailable: false,
|
|
23
|
+
latestVersion: config?.version ?? 'unknown',
|
|
24
|
+
releaseNotesUrl: '',
|
|
25
|
+
})
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const instanceId = url.searchParams.get('instanceId') ?? ''
|
|
29
|
+
|
|
30
|
+
try {
|
|
31
|
+
const response = await fetch(
|
|
32
|
+
`${updaterUrl}/api/check-update?instanceId=${encodeURIComponent(instanceId)}`,
|
|
33
|
+
{ signal: AbortSignal.timeout(5000) },
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
if (!response.ok) {
|
|
37
|
+
throw new Error(`HTTP ${response.status}`)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const data = await response.json()
|
|
41
|
+
return Response.json(data)
|
|
42
|
+
} catch {
|
|
43
|
+
return Response.json({
|
|
44
|
+
updateAvailable: false,
|
|
45
|
+
latestVersion: config?.version ?? 'unknown',
|
|
46
|
+
releaseNotesUrl: '',
|
|
47
|
+
})
|
|
48
|
+
}
|
|
49
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import type { APIRoute } from 'astro'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Registers this Setzkasten instance with the central updater backend.
|
|
5
|
+
* Called on every Dashboard load. Returns update status and license tier.
|
|
6
|
+
*
|
|
7
|
+
* POST /api/setzkasten/updater/register
|
|
8
|
+
*
|
|
9
|
+
* Body (optional — for UI activation flow):
|
|
10
|
+
* { licenseEmail: string, licenseKey: string }
|
|
11
|
+
*
|
|
12
|
+
* Priority for credentials:
|
|
13
|
+
* 1. Config (`setzkasten.config.ts` → license.{email,key}) — always wins if set
|
|
14
|
+
* 2. Request body — UI activation flow, one-time
|
|
15
|
+
* 3. Firebase instance fallback — stored binding from previous activation
|
|
16
|
+
*/
|
|
17
|
+
export const POST: APIRoute = async ({ cookies, request }) => {
|
|
18
|
+
const session = cookies.get('setzkasten_session')?.value
|
|
19
|
+
if (!session) {
|
|
20
|
+
return new Response('Unauthorized', { status: 401 })
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const config = (globalThis as any).__SETZKASTEN_CONFIG__ as {
|
|
24
|
+
updaterUrl?: string
|
|
25
|
+
version?: string
|
|
26
|
+
websiteUrl?: string
|
|
27
|
+
storage?: { owner?: string; repo?: string }
|
|
28
|
+
} | undefined
|
|
29
|
+
|
|
30
|
+
const fullConfig = (globalThis as any).__SETZKASTEN_FULL_CONFIG__ as {
|
|
31
|
+
license?: { email?: string; key?: string; telemetryEnabled?: boolean }
|
|
32
|
+
} | undefined
|
|
33
|
+
|
|
34
|
+
const currentVersion = config?.version ?? '0.0.0'
|
|
35
|
+
const updaterUrl = config?.updaterUrl
|
|
36
|
+
if (!updaterUrl) {
|
|
37
|
+
return Response.json({
|
|
38
|
+
instanceId: null,
|
|
39
|
+
updateAvailable: false,
|
|
40
|
+
currentVersion,
|
|
41
|
+
latestVersion: null,
|
|
42
|
+
releaseNotesUrl: '',
|
|
43
|
+
licenseTier: 'free',
|
|
44
|
+
licenseValid: false,
|
|
45
|
+
})
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const owner = config?.storage?.owner ?? ''
|
|
49
|
+
const repo = config?.storage?.repo ?? ''
|
|
50
|
+
const repoUrl = owner && repo ? `${owner}/${repo}` : ''
|
|
51
|
+
const websiteUrl = config?.websiteUrl ?? ''
|
|
52
|
+
const configLicense = fullConfig?.license
|
|
53
|
+
|
|
54
|
+
// ── Parse optional UI activation payload ──────────────────────────────
|
|
55
|
+
let uiEmail: string | undefined
|
|
56
|
+
let uiKey: string | undefined
|
|
57
|
+
try {
|
|
58
|
+
if (request.headers.get('content-type')?.includes('application/json')) {
|
|
59
|
+
const parsed = await request.json() as { licenseEmail?: string; licenseKey?: string }
|
|
60
|
+
uiEmail = parsed.licenseEmail?.trim() || undefined
|
|
61
|
+
uiKey = parsed.licenseKey?.trim() || undefined
|
|
62
|
+
}
|
|
63
|
+
} catch {
|
|
64
|
+
// Empty / malformed body is fine — treat as no UI input
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Config wins over UI. UI only applies if config has no license.
|
|
68
|
+
const licenseEmail = configLicense?.email ?? uiEmail
|
|
69
|
+
const licenseKey = configLicense?.key ?? uiKey
|
|
70
|
+
|
|
71
|
+
try {
|
|
72
|
+
const response = await fetch(`${updaterUrl}/api/register`, {
|
|
73
|
+
method: 'POST',
|
|
74
|
+
headers: { 'Content-Type': 'application/json' },
|
|
75
|
+
body: JSON.stringify({
|
|
76
|
+
repoUrl,
|
|
77
|
+
websiteUrl,
|
|
78
|
+
currentVersion,
|
|
79
|
+
licenseEmail,
|
|
80
|
+
licenseKey,
|
|
81
|
+
telemetryEnabled: configLicense?.telemetryEnabled !== false,
|
|
82
|
+
managedWebsites: [],
|
|
83
|
+
}),
|
|
84
|
+
signal: AbortSignal.timeout(5000),
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
if (!response.ok) {
|
|
88
|
+
throw new Error(`HTTP ${response.status}`)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const data = await response.json() as { firebaseConfig?: { apiKey: string; authDomain: string; projectId: string }; [key: string]: unknown }
|
|
92
|
+
|
|
93
|
+
// Pass firebaseConfig through if the Updater returned one (Pro/Enterprise licenses).
|
|
94
|
+
// Writing to _global_config.json is done explicitly via the GlobalConfigView activation flow.
|
|
95
|
+
return Response.json({ ...data, currentVersion, _firebaseConfig: data.firebaseConfig ?? null })
|
|
96
|
+
} catch {
|
|
97
|
+
return Response.json({
|
|
98
|
+
instanceId: null,
|
|
99
|
+
updateAvailable: false,
|
|
100
|
+
currentVersion,
|
|
101
|
+
latestVersion: null,
|
|
102
|
+
releaseNotesUrl: '',
|
|
103
|
+
licenseTier: 'free',
|
|
104
|
+
licenseValid: false,
|
|
105
|
+
})
|
|
106
|
+
}
|
|
107
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import type { APIRoute } from 'astro'
|
|
2
|
+
import { createHash } from 'node:crypto'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Transfer a license to the current Setzkasten instance.
|
|
6
|
+
*
|
|
7
|
+
* POST /api/setzkasten/updater/transfer
|
|
8
|
+
*/
|
|
9
|
+
export const POST: APIRoute = async ({ cookies }) => {
|
|
10
|
+
const session = cookies.get('setzkasten_session')?.value
|
|
11
|
+
if (!session) {
|
|
12
|
+
return new Response('Unauthorized', { status: 401 })
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const config = (globalThis as any).__SETZKASTEN_CONFIG__ as {
|
|
16
|
+
updaterUrl?: string
|
|
17
|
+
websiteUrl?: string
|
|
18
|
+
storage?: { owner?: string; repo?: string }
|
|
19
|
+
} | undefined
|
|
20
|
+
|
|
21
|
+
const fullConfig = (globalThis as any).__SETZKASTEN_FULL_CONFIG__ as {
|
|
22
|
+
license?: { email?: string; key?: string }
|
|
23
|
+
} | undefined
|
|
24
|
+
|
|
25
|
+
const updaterUrl = config?.updaterUrl
|
|
26
|
+
if (!updaterUrl) {
|
|
27
|
+
return Response.json({ error: 'Updater not configured' }, { status: 400 })
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const license = fullConfig?.license
|
|
31
|
+
if (!license?.key || !license?.email) {
|
|
32
|
+
return Response.json({ error: 'No license configured' }, { status: 400 })
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const owner = config?.storage?.owner ?? ''
|
|
36
|
+
const repo = config?.storage?.repo ?? ''
|
|
37
|
+
const repoUrl = owner && repo ? `${owner}/${repo}` : ''
|
|
38
|
+
const websiteUrl = config?.websiteUrl ?? ''
|
|
39
|
+
|
|
40
|
+
// Compute deterministic instanceId (same as backend register)
|
|
41
|
+
const raw = (repoUrl || 'unknown') + '|' + (websiteUrl || 'unknown')
|
|
42
|
+
const instanceId = createHash('sha256').update(raw).digest('hex').slice(0, 32)
|
|
43
|
+
|
|
44
|
+
try {
|
|
45
|
+
const response = await fetch(`${updaterUrl}/api/transfer`, {
|
|
46
|
+
method: 'POST',
|
|
47
|
+
headers: { 'Content-Type': 'application/json' },
|
|
48
|
+
body: JSON.stringify({
|
|
49
|
+
licenseKey: license.key,
|
|
50
|
+
licenseEmail: license.email,
|
|
51
|
+
toInstanceId: instanceId,
|
|
52
|
+
toWebsiteUrl: websiteUrl,
|
|
53
|
+
}),
|
|
54
|
+
signal: AbortSignal.timeout(5000),
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
const data = await response.json()
|
|
58
|
+
return Response.json(data, { status: response.status })
|
|
59
|
+
} catch {
|
|
60
|
+
return Response.json({ error: 'Transfer failed' }, { status: 500 })
|
|
61
|
+
}
|
|
62
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { APIRoute } from 'astro'
|
|
2
|
+
import { createHash } from 'node:crypto'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Release the license binding for this Setzkasten instance.
|
|
6
|
+
*
|
|
7
|
+
* POST /api/setzkasten/updater/unbind
|
|
8
|
+
*
|
|
9
|
+
* Reads license credentials from either:
|
|
10
|
+
* 1. `setzkasten.config.ts` → license.{email,key}
|
|
11
|
+
* 2. Request body (UI removal flow): { licenseEmail, licenseKey }
|
|
12
|
+
*
|
|
13
|
+
* On success, Firebase clears `instance.licenseKey` and removes this
|
|
14
|
+
* instance from `license.boundTo` / `license.boundInstances`.
|
|
15
|
+
*/
|
|
16
|
+
export const POST: APIRoute = async ({ cookies, request }) => {
|
|
17
|
+
const session = cookies.get('setzkasten_session')?.value
|
|
18
|
+
if (!session) {
|
|
19
|
+
return new Response('Unauthorized', { status: 401 })
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const config = (globalThis as any).__SETZKASTEN_CONFIG__ as {
|
|
23
|
+
updaterUrl?: string
|
|
24
|
+
websiteUrl?: string
|
|
25
|
+
storage?: { owner?: string; repo?: string }
|
|
26
|
+
} | undefined
|
|
27
|
+
|
|
28
|
+
const fullConfig = (globalThis as any).__SETZKASTEN_FULL_CONFIG__ as {
|
|
29
|
+
license?: { email?: string; key?: string }
|
|
30
|
+
} | undefined
|
|
31
|
+
|
|
32
|
+
const updaterUrl = config?.updaterUrl
|
|
33
|
+
if (!updaterUrl) {
|
|
34
|
+
return Response.json({ error: 'Updater not configured' }, { status: 400 })
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const owner = config?.storage?.owner ?? ''
|
|
38
|
+
const repo = config?.storage?.repo ?? ''
|
|
39
|
+
const repoUrl = owner && repo ? `${owner}/${repo}` : ''
|
|
40
|
+
const websiteUrl = config?.websiteUrl ?? ''
|
|
41
|
+
|
|
42
|
+
// Compute deterministic instanceId (same as backend register)
|
|
43
|
+
const raw = (repoUrl || 'unknown') + '|' + (websiteUrl || 'unknown')
|
|
44
|
+
const instanceId = createHash('sha256').update(raw).digest('hex').slice(0, 32)
|
|
45
|
+
|
|
46
|
+
try {
|
|
47
|
+
const response = await fetch(`${updaterUrl}/api/unbind`, {
|
|
48
|
+
method: 'POST',
|
|
49
|
+
headers: { 'Content-Type': 'application/json' },
|
|
50
|
+
body: JSON.stringify({ instanceId }),
|
|
51
|
+
signal: AbortSignal.timeout(5000),
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
const data = await response.json()
|
|
55
|
+
return Response.json(data, { status: response.status })
|
|
56
|
+
} catch {
|
|
57
|
+
return Response.json({ error: 'Unbind failed' }, { status: 500 })
|
|
58
|
+
}
|
|
59
|
+
}
|
|
@@ -1031,3 +1031,50 @@ describe('Nested map live-edit: inner items have data-sk-field', () => {
|
|
|
1031
1031
|
expect(patched).toMatch(/sections.*map.*section.*_i/)
|
|
1032
1032
|
})
|
|
1033
1033
|
})
|
|
1034
|
+
|
|
1035
|
+
// ---------------------------------------------------------------------------
|
|
1036
|
+
// Template literal variables in frontmatter must NOT become content fields
|
|
1037
|
+
// Reproduces: docs/catalog page with code examples in template literals
|
|
1038
|
+
// ---------------------------------------------------------------------------
|
|
1039
|
+
|
|
1040
|
+
describe('Page-level: template literal variables not extracted as fields', () => {
|
|
1041
|
+
let section: Awaited<ReturnType<typeof analyzeAstroSection>>
|
|
1042
|
+
|
|
1043
|
+
beforeAll(async () => {
|
|
1044
|
+
const source = readFileSync(join(SECTIONS_DIR, 'PageWithTemplateLiterals.astro'), 'utf-8')
|
|
1045
|
+
section = await analyzeAstroSection(source, '_page_docs_example', 'docsExamplePage', 'src/pages/docs/example.astro', { mode: 'page' })
|
|
1046
|
+
})
|
|
1047
|
+
|
|
1048
|
+
it('should detect the real content fields (heading, description)', () => {
|
|
1049
|
+
const keys = section.fields.map(f => f.key)
|
|
1050
|
+
expect(keys).toContain('heading')
|
|
1051
|
+
expect(keys).toContain('description')
|
|
1052
|
+
})
|
|
1053
|
+
|
|
1054
|
+
it('should NOT extract variables from inside template literal strings (templates, hero, json, template)', () => {
|
|
1055
|
+
const keys = section.fields.map(f => f.key)
|
|
1056
|
+
expect(keys).not.toContain('templates')
|
|
1057
|
+
expect(keys).not.toContain('hero')
|
|
1058
|
+
expect(keys).not.toContain('json')
|
|
1059
|
+
expect(keys).not.toContain('template')
|
|
1060
|
+
})
|
|
1061
|
+
|
|
1062
|
+
it('should NOT extract the code variables themselves (exampleCode, cliCode, apiCode)', () => {
|
|
1063
|
+
const keys = section.fields.map(f => f.key)
|
|
1064
|
+
expect(keys).not.toContain('exampleCode')
|
|
1065
|
+
expect(keys).not.toContain('cliCode')
|
|
1066
|
+
expect(keys).not.toContain('apiCode')
|
|
1067
|
+
})
|
|
1068
|
+
|
|
1069
|
+
it('should find only legitimate fields (no spurious template-literal variables)', () => {
|
|
1070
|
+
const keys = section.fields.map(f => f.key)
|
|
1071
|
+
// Only real content fields should be present — no code variable names
|
|
1072
|
+
const spurious = ['templates', 'hero', 'json', 'template', 'exampleCode', 'cliCode', 'apiCode']
|
|
1073
|
+
for (const key of spurious) {
|
|
1074
|
+
expect(keys).not.toContain(key)
|
|
1075
|
+
}
|
|
1076
|
+
// heading and description must be present
|
|
1077
|
+
expect(keys).toContain('heading')
|
|
1078
|
+
expect(keys).toContain('description')
|
|
1079
|
+
})
|
|
1080
|
+
})
|
|
@@ -68,7 +68,9 @@ function checkCssIntegrity(
|
|
|
68
68
|
return { ok: lost.length === 0, lost }
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
-
|
|
71
|
+
// icon and image fields now get data-sk-field via patchIconOrImageField
|
|
72
|
+
// (only when the patcher can find a suitable container in the AST)
|
|
73
|
+
const SK_FIELD_NOT_NEEDED = new Set<string>([])
|
|
72
74
|
|
|
73
75
|
function fieldNeedsSkBinding(field: { key: string; type: string }): boolean {
|
|
74
76
|
if (SK_FIELD_NOT_NEEDED.has(field.type)) return false
|
|
@@ -201,16 +201,43 @@ function splitAstroFile(source: string): { frontmatter: string; template: string
|
|
|
201
201
|
return { frontmatter, template: source.slice(templateStart), templateOffset: templateStart }
|
|
202
202
|
}
|
|
203
203
|
|
|
204
|
+
function stripTemplateLiterals(source: string): string {
|
|
205
|
+
let result = ''
|
|
206
|
+
let i = 0
|
|
207
|
+
while (i < source.length) {
|
|
208
|
+
if (source[i] === '`') {
|
|
209
|
+
// Skip entire template literal (including nested ${...} expressions)
|
|
210
|
+
i++
|
|
211
|
+
let depth = 0
|
|
212
|
+
while (i < source.length) {
|
|
213
|
+
if (source[i] === '\\') { i += 2; continue }
|
|
214
|
+
if (source[i] === '$' && source[i + 1] === '{') { depth++; i += 2; continue }
|
|
215
|
+
if (source[i] === '{') { depth++; i++; continue }
|
|
216
|
+
if (source[i] === '}' && depth > 0) { depth--; i++; continue }
|
|
217
|
+
if (source[i] === '`' && depth === 0) { i++; break }
|
|
218
|
+
i++
|
|
219
|
+
}
|
|
220
|
+
result += '``' // placeholder so surrounding code stays parseable
|
|
221
|
+
} else {
|
|
222
|
+
result += source[i]
|
|
223
|
+
i++
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
return result
|
|
227
|
+
}
|
|
228
|
+
|
|
204
229
|
function extractFrontmatterVariables(frontmatter: string): string[] {
|
|
230
|
+
// Strip template literals so code inside backtick strings isn't parsed as variables
|
|
231
|
+
const stripped = stripTemplateLiterals(frontmatter)
|
|
205
232
|
const variables: string[] = []
|
|
206
233
|
const constRegex = /(?:const|let)\s+(\w+)\s*=\s*(.*)/g
|
|
207
234
|
let match: RegExpExecArray | null
|
|
208
|
-
while ((match = constRegex.exec(
|
|
235
|
+
while ((match = constRegex.exec(stripped)) !== null) {
|
|
209
236
|
const name = match[1]!
|
|
210
237
|
const rhs = match[2]?.trim() ?? ''
|
|
211
238
|
if (isInternalVariable(name)) continue
|
|
212
239
|
// Skip exported declarations (e.g. "export const prerender = true")
|
|
213
|
-
const charBefore = match.index > 0 ?
|
|
240
|
+
const charBefore = match.index > 0 ? stripped.slice(Math.max(0, match.index - 10), match.index) : ''
|
|
214
241
|
if (/export\s*$/.test(charBefore)) continue
|
|
215
242
|
if (/\.\s*map\s*\(/.test(rhs) || /\w+\?\.\w+/.test(rhs)) continue
|
|
216
243
|
if (/^\[/.test(rhs) && /^default/i.test(name)) continue
|
|
@@ -296,6 +296,15 @@ export async function patchTemplateForFields(
|
|
|
296
296
|
continue
|
|
297
297
|
}
|
|
298
298
|
|
|
299
|
+
// --- Icon / Image fields: add data-sk-field to their container element ---
|
|
300
|
+
// These fields can't be matched by text content. Instead we look for:
|
|
301
|
+
// 1. A CMS expression referencing this field (e.g. {skData?.icon} as an attr value)
|
|
302
|
+
// 2. A component/element with an `icon` or `src`/image prop that matches the default
|
|
303
|
+
if (field.type === 'icon' || field.type === 'image') {
|
|
304
|
+
patchIconOrImageField(source, sectionKey, field, varName, ast, edits)
|
|
305
|
+
continue
|
|
306
|
+
}
|
|
307
|
+
|
|
299
308
|
// Fields without string defaultValue can't be matched by content
|
|
300
309
|
if (!field.defaultValue || typeof field.defaultValue !== 'string') continue
|
|
301
310
|
if (field.defaultValue.length < 2) continue
|
|
@@ -607,6 +616,64 @@ function patchMixedContentField(
|
|
|
607
616
|
})
|
|
608
617
|
}
|
|
609
618
|
|
|
619
|
+
/**
|
|
620
|
+
* Patches an icon or image field by finding the nearest suitable container
|
|
621
|
+
* in the AST and adding data-sk-field to it.
|
|
622
|
+
*
|
|
623
|
+
* Strategy:
|
|
624
|
+
* - For icon: look for an element whose `icon`, `name`, or `data-icon` attribute
|
|
625
|
+
* matches the default value, or a component named *Icon* — then add data-sk-field
|
|
626
|
+
* to its parent container element.
|
|
627
|
+
* - For image: look for <img> / <Image> element whose `src` matches the default,
|
|
628
|
+
* or an element whose expression refs the field key — add data-sk-field to parent.
|
|
629
|
+
* - Fallback: skip (no edit, no crash).
|
|
630
|
+
*/
|
|
631
|
+
function patchIconOrImageField(
|
|
632
|
+
source: string,
|
|
633
|
+
sectionKey: string,
|
|
634
|
+
field: { key: string; type: string; defaultValue?: unknown },
|
|
635
|
+
_varName: string,
|
|
636
|
+
ast: AstNode,
|
|
637
|
+
edits: Edit[],
|
|
638
|
+
): void {
|
|
639
|
+
const bindingKey = `${sectionKey}.${field.key}`
|
|
640
|
+
const defaultStr = typeof field.defaultValue === 'string' ? field.defaultValue : ''
|
|
641
|
+
|
|
642
|
+
let targetParent: AstNode | null = null
|
|
643
|
+
|
|
644
|
+
walkAst(ast, (node, parent) => {
|
|
645
|
+
if (targetParent) return // already found
|
|
646
|
+
if (!parent || !isElement(parent)) return
|
|
647
|
+
// Skip if parent already has data-sk-field
|
|
648
|
+
if (parent.attributes?.some(a => a.name === 'data-sk-field')) return
|
|
649
|
+
|
|
650
|
+
if (field.type === 'icon') {
|
|
651
|
+
// Match component named *Icon* or element with icon/name attr matching default
|
|
652
|
+
const isIconComp = isElement(node) && /icon/i.test(node.name ?? '')
|
|
653
|
+
const hasIconAttr = node.attributes?.some(
|
|
654
|
+
a => (a.name === 'icon' || a.name === 'name' || a.name === 'data-icon') &&
|
|
655
|
+
(a.value === defaultStr || a.value.includes(field.key))
|
|
656
|
+
)
|
|
657
|
+
if (isIconComp || hasIconAttr) {
|
|
658
|
+
targetParent = parent
|
|
659
|
+
}
|
|
660
|
+
} else if (field.type === 'image') {
|
|
661
|
+
// Match <img> / <Image> / <picture> element
|
|
662
|
+
const tag = node.name ?? ''
|
|
663
|
+
const isImgEl = /^(img|Image|picture)$/.test(tag)
|
|
664
|
+
const hasSrcAttr = node.attributes?.some(
|
|
665
|
+
a => a.name === 'src' && (a.value === defaultStr || a.value.includes(field.key))
|
|
666
|
+
)
|
|
667
|
+
if (isImgEl || hasSrcAttr) {
|
|
668
|
+
targetParent = parent
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
})
|
|
672
|
+
|
|
673
|
+
if (!targetParent) return
|
|
674
|
+
addAttributeToElement(source, targetParent, `data-sk-field="${bindingKey}"`, edits)
|
|
675
|
+
}
|
|
676
|
+
|
|
610
677
|
function patchCmsBoundField(
|
|
611
678
|
source: string,
|
|
612
679
|
sectionKey: string,
|