@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,434 @@
1
+ /**
2
+ * Patcher edge-case tests.
3
+ *
4
+ * Covers scenarios that have caused silent failures or build errors in
5
+ * production and are not covered by the page-mode regression tests:
6
+ *
7
+ * 1. UTF-8 / multi-byte characters (umlauts, em-dash, emoji)
8
+ * AST byte offsets vs JS char offsets — wrong replacement if not converted.
9
+ *
10
+ * 2. Re-patching an already-integrated page
11
+ * Calling patchTemplateForFields on a file that already has
12
+ * `getSection()` must be idempotent (no duplicate imports, no errors).
13
+ *
14
+ * 3. Windows line-endings (CRLF)
15
+ * Frontmatter extraction, offset arithmetic, and semicolon stripping
16
+ * must work correctly when \r\n is used throughout.
17
+ *
18
+ * 4. Frontmatter without trailing newline
19
+ * The patcher must not crash or produce a malformed result when the
20
+ * source does not end with a newline.
21
+ *
22
+ * 5. Page with no patchable content
23
+ * A template whose text nodes do not match any defaultValue should
24
+ * still inject the getSection() call without throwing.
25
+ */
26
+
27
+ import { describe, it, expect } from 'vitest'
28
+ import { patchTemplateForFields, stripTemplateFallbacks } from '../../init/template-patcher-v2'
29
+ import type { PatchField } from '../../init/template-patcher-v2'
30
+
31
+ // ---------------------------------------------------------------------------
32
+ // Edge Case 1: UTF-8 / multi-byte characters
33
+ //
34
+ // If the patcher uses raw byte offsets from the AST instead of JS char offsets,
35
+ // characters like ä (2 bytes in UTF-8) cause the replacement range to be off
36
+ // by one or more positions, silently corrupting text around non-ASCII chars.
37
+ // ---------------------------------------------------------------------------
38
+
39
+ describe('Edge case 1: UTF-8 multi-byte characters', () => {
40
+ const source = `---
41
+ export const prerender = true;
42
+ import BaseLayout from '../layouts/BaseLayout.astro';
43
+ ---
44
+
45
+ <BaseLayout>
46
+ <section class="py-16">
47
+ <h1>Über uns</h1>
48
+ <p>Wir entwickeln Lösungen für Unternehmen.</p>
49
+ </section>
50
+ </BaseLayout>
51
+ `
52
+
53
+ const fields: PatchField[] = [
54
+ { key: 'heading', type: 'text', defaultValue: 'Über uns' },
55
+ { key: 'body', type: 'text', defaultValue: 'Wir entwickeln Lösungen für Unternehmen.' },
56
+ ]
57
+
58
+ it('should produce valid output containing skData references', async () => {
59
+ const patched = await patchTemplateForFields(source, '_page_about', fields, [], { mode: 'page' })
60
+ expect(patched).toContain('skData')
61
+ })
62
+
63
+ it('should bind the heading field containing an umlaut', async () => {
64
+ const patched = await patchTemplateForFields(source, '_page_about', fields, [], { mode: 'page' })
65
+ expect(patched).toContain('skData?.heading')
66
+ })
67
+
68
+ it('should bind the body field containing umlauts', async () => {
69
+ const patched = await patchTemplateForFields(source, '_page_about', fields, [], { mode: 'page' })
70
+ expect(patched).toContain('skData?.body')
71
+ })
72
+
73
+ it('should not corrupt the surrounding markup', async () => {
74
+ const patched = await patchTemplateForFields(source, '_page_about', fields, [], { mode: 'page' })
75
+ // The <section> tag must still be intact
76
+ expect(patched).toMatch(/<section[^>]*>/)
77
+ // Closing tags must still be present
78
+ expect(patched).toContain('</section>')
79
+ expect(patched).toContain('</BaseLayout>')
80
+ })
81
+
82
+ it('should add data-sk-field to the heading element', async () => {
83
+ const patched = await patchTemplateForFields(source, '_page_about', fields, [], { mode: 'page' })
84
+ expect(patched).toMatch(/<h1[^>]*data-sk-field/)
85
+ })
86
+ })
87
+
88
+ // ---------------------------------------------------------------------------
89
+ // Edge Case 2: Re-patching an already-integrated page (idempotency)
90
+ //
91
+ // If the user runs adoption twice, or if the patcher is called on a file that
92
+ // was already patched (e.g. by a previous adoption commit), it must:
93
+ // - NOT add a second `import { getSection }` line
94
+ // - NOT add a second `const skData = getSection(...)` declaration
95
+ // - NOT break any existing bindings
96
+ // ---------------------------------------------------------------------------
97
+
98
+ describe('Edge case 2: Re-patching an already-integrated page is idempotent', () => {
99
+ // Simulate a page that was already patched by a previous adoption run
100
+ const alreadyPatched = `---
101
+ export const prerender = true;
102
+ import BaseLayout from '../layouts/BaseLayout.astro';
103
+ import { getSection } from 'setzkasten:content'
104
+ const skData = getSection('_page_about')
105
+ ---
106
+
107
+ <BaseLayout>
108
+ <section id="section-_page_about" class="py-16">
109
+ <h1 data-sk-field="_page_about.heading" set:html={skData?.heading ?? 'Über uns'} />
110
+ <p data-sk-field="_page_about.body" set:html={skData?.body ?? 'Wir entwickeln Lösungen.'} />
111
+ </section>
112
+ </BaseLayout>
113
+ `
114
+
115
+ const fields: PatchField[] = [
116
+ { key: 'heading', type: 'text', defaultValue: 'Über uns' },
117
+ { key: 'body', type: 'text', defaultValue: 'Wir entwickeln Lösungen.' },
118
+ ]
119
+
120
+ it('should not duplicate the getSection import', async () => {
121
+ const patched = await patchTemplateForFields(alreadyPatched, '_page_about', fields, [], { mode: 'page' })
122
+ const importCount = (patched.match(/import \{ getSection \}/g) ?? []).length
123
+ expect(importCount).toBe(1)
124
+ })
125
+
126
+ it('should not duplicate the skData declaration', async () => {
127
+ const patched = await patchTemplateForFields(alreadyPatched, '_page_about', fields, [], { mode: 'page' })
128
+ const declCount = (patched.match(/const skData\s*=/g) ?? []).length
129
+ expect(declCount).toBe(1)
130
+ })
131
+
132
+ it('should preserve existing data-sk-field attributes', async () => {
133
+ const patched = await patchTemplateForFields(alreadyPatched, '_page_about', fields, [], { mode: 'page' })
134
+ expect(patched).toContain('data-sk-field="_page_about.heading"')
135
+ expect(patched).toContain('data-sk-field="_page_about.body"')
136
+ })
137
+
138
+ it('should not add a second section id', async () => {
139
+ const patched = await patchTemplateForFields(alreadyPatched, '_page_about', fields, [], { mode: 'page' })
140
+ const idCount = (patched.match(/id="section-_page_about"/g) ?? []).length
141
+ expect(idCount).toBe(1)
142
+ })
143
+ })
144
+
145
+ // ---------------------------------------------------------------------------
146
+ // Edge Case 3: Windows line-endings (CRLF)
147
+ //
148
+ // Source files checked out on Windows may use \r\n. The patcher uses
149
+ // indexOf('---') and slice operations that must still work correctly.
150
+ // The resulting output does not need to be CRLF; it just must be valid.
151
+ // ---------------------------------------------------------------------------
152
+
153
+ describe('Edge case 3: CRLF line endings', () => {
154
+ const lf = `---
155
+ export const prerender = true;
156
+ import BaseLayout from '../layouts/BaseLayout.astro';
157
+ ---
158
+
159
+ <BaseLayout>
160
+ <section class="py-16">
161
+ <h1>Hello World</h1>
162
+ </section>
163
+ </BaseLayout>
164
+ `
165
+ // Convert all LF to CRLF
166
+ const crlf = lf.replace(/\n/g, '\r\n')
167
+
168
+ const fields: PatchField[] = [
169
+ { key: 'heading', type: 'text', defaultValue: 'Hello World' },
170
+ ]
171
+
172
+ it('should not throw on CRLF input', async () => {
173
+ await expect(
174
+ patchTemplateForFields(crlf, '_page_test', fields, [], { mode: 'page' })
175
+ ).resolves.toBeDefined()
176
+ })
177
+
178
+ it('should still inject getSection', async () => {
179
+ const patched = await patchTemplateForFields(crlf, '_page_test', fields, [], { mode: 'page' })
180
+ expect(patched).toContain('getSection')
181
+ })
182
+
183
+ it('should still bind the heading field', async () => {
184
+ const patched = await patchTemplateForFields(crlf, '_page_test', fields, [], { mode: 'page' })
185
+ expect(patched).toContain('skData?.heading')
186
+ })
187
+ })
188
+
189
+ // ---------------------------------------------------------------------------
190
+ // Edge Case 4: Frontmatter without trailing newline before closing ---
191
+ //
192
+ // Some editors save files without a trailing newline. The patcher uses
193
+ // `source.indexOf('---', start + 3)` to find the closing frontmatter marker;
194
+ // this must still work even if the last frontmatter line has no newline.
195
+ // ---------------------------------------------------------------------------
196
+
197
+ describe('Edge case 4: frontmatter without trailing newline', () => {
198
+ // Note: no \n before closing ---
199
+ const source = `---
200
+ export const prerender = true;
201
+ import BaseLayout from '../layouts/BaseLayout.astro';---
202
+
203
+ <BaseLayout>
204
+ <section class="py-16">
205
+ <h1>Tight Frontmatter</h1>
206
+ </section>
207
+ </BaseLayout>
208
+ `
209
+
210
+ const fields: PatchField[] = [
211
+ { key: 'heading', type: 'text', defaultValue: 'Tight Frontmatter' },
212
+ ]
213
+
214
+ it('should not throw', async () => {
215
+ await expect(
216
+ patchTemplateForFields(source, '_page_test', fields, [], { mode: 'page' })
217
+ ).resolves.toBeDefined()
218
+ })
219
+
220
+ it('should return a string (graceful fallback at minimum)', async () => {
221
+ const result = await patchTemplateForFields(source, '_page_test', fields, [], { mode: 'page' })
222
+ expect(typeof result).toBe('string')
223
+ expect(result.length).toBeGreaterThan(0)
224
+ })
225
+ })
226
+
227
+ // ---------------------------------------------------------------------------
228
+ // Edge Case 5: No patchable content
229
+ //
230
+ // A template whose text nodes don't match any field defaultValue should:
231
+ // - Still inject getSection() in page mode
232
+ // - Not throw
233
+ // - Return a non-empty string
234
+ // ---------------------------------------------------------------------------
235
+
236
+ describe('Edge case 5: template with no matching content', () => {
237
+ const source = `---
238
+ export const prerender = true;
239
+ import BaseLayout from '../layouts/BaseLayout.astro';
240
+ ---
241
+
242
+ <BaseLayout>
243
+ <section class="py-16">
244
+ <h1>Something completely different</h1>
245
+ </section>
246
+ </BaseLayout>
247
+ `
248
+
249
+ const fields: PatchField[] = [
250
+ { key: 'heading', type: 'text', defaultValue: 'Expected Heading That Does Not Appear' },
251
+ ]
252
+
253
+ it('should not throw', async () => {
254
+ await expect(
255
+ patchTemplateForFields(source, '_page_test', fields, [], { mode: 'page' })
256
+ ).resolves.toBeDefined()
257
+ })
258
+
259
+ it('should inject getSection even with no matching content', async () => {
260
+ const patched = await patchTemplateForFields(source, '_page_test', fields, [], { mode: 'page' })
261
+ expect(patched).toContain('getSection')
262
+ })
263
+
264
+ it('should not duplicate frontmatter markers', async () => {
265
+ const patched = await patchTemplateForFields(source, '_page_test', fields, [], { mode: 'page' })
266
+ const markerCount = (patched.match(/^---$/gm) ?? []).length
267
+ // Should have exactly 2 frontmatter delimiters
268
+ expect(markerCount).toBe(2)
269
+ })
270
+ })
271
+
272
+ // ---------------------------------------------------------------------------
273
+ // stripTemplateFallbacks
274
+ // ---------------------------------------------------------------------------
275
+ describe('stripTemplateFallbacks', () => {
276
+ it('strips single-quoted string fallback and extracts value', () => {
277
+ const source = `<h1 set:html={skData?.heading ?? 'Lizenz'}></h1>`
278
+ const { source: result, fallbacks } = stripTemplateFallbacks(source)
279
+ expect(result).toBe(`<h1 set:html={skData?.heading}></h1>`)
280
+ expect(fallbacks['heading']).toBe('Lizenz')
281
+ })
282
+
283
+ it('strips double-quoted string fallback', () => {
284
+ const source = `<p set:html={skData?.desc ?? "Beschreibung"}></p>`
285
+ const { source: result, fallbacks } = stripTemplateFallbacks(source)
286
+ expect(result).toBe(`<p set:html={skData?.desc}></p>`)
287
+ expect(fallbacks['desc']).toBe('Beschreibung')
288
+ })
289
+
290
+ it('strips template-literal fallback (single line)', () => {
291
+ const source = `<p set:html={skData?.description ?? \`Setzkasten ist ein CMS.\`}></p>`
292
+ const { source: result, fallbacks } = stripTemplateFallbacks(source)
293
+ expect(result).toBe(`<p set:html={skData?.description}></p>`)
294
+ expect(fallbacks['description']).toBe('Setzkasten ist ein CMS.')
295
+ })
296
+
297
+ it('strips multi-line template-literal fallback', () => {
298
+ const source = `<p set:html={skData?.description ?? \`Es läuft vollständig im Browser und\n kommuniziert über API-Routen.\`}></p>`
299
+ const { source: result, fallbacks } = stripTemplateFallbacks(source)
300
+ expect(result).toBe(`<p set:html={skData?.description}></p>`)
301
+ expect(fallbacks['description']).toContain('läuft vollständig')
302
+ })
303
+
304
+ it('strips array fallback and extracts items', () => {
305
+ const source = `{(skData?.items ?? ['Eintrag A', 'Eintrag B', 'Eintrag C']).map((`
306
+ const { source: result, fallbacks } = stripTemplateFallbacks(source)
307
+ expect(result).toBe(`{(skData?.items ?? []).map((`)
308
+ expect(fallbacks['items']).toEqual(['Eintrag A', 'Eintrag B', 'Eintrag C'])
309
+ })
310
+
311
+ it('strips array fallback with backtick HTML items', () => {
312
+ const source = "{(skData?.items ?? [`<strong>Label</strong> &mdash; Text.`]).map(("
313
+ const { source: result, fallbacks } = stripTemplateFallbacks(source)
314
+ expect(result).toBe(`{(skData?.items ?? []).map((`)
315
+ expect((fallbacks['items'] as string[])?.[0]).toContain('<strong>')
316
+ })
317
+
318
+ it('leaves elements without fallbacks untouched', () => {
319
+ const source = `<h1 set:html={skData?.heading}></h1>`
320
+ const { source: result, fallbacks } = stripTemplateFallbacks(source)
321
+ expect(result).toBe(source)
322
+ expect(Object.keys(fallbacks)).toHaveLength(0)
323
+ })
324
+
325
+ it('does not overwrite existing JSON values (caller responsibility)', () => {
326
+ // stripTemplateFallbacks just returns fallbacks — merging is done in add-section
327
+ // This test verifies multiple fallbacks are all extracted
328
+ const source = [
329
+ `<h1 set:html={skData?.heading ?? 'Titel'}></h1>`,
330
+ `<p set:html={skData?.description ?? 'Text'}></p>`,
331
+ `{(skData?.items ?? ['A', 'B']).map((`,
332
+ ].join('\n')
333
+ const { fallbacks } = stripTemplateFallbacks(source)
334
+ expect(fallbacks['heading']).toBe('Titel')
335
+ expect(fallbacks['description']).toBe('Text')
336
+ expect(fallbacks['items']).toEqual(['A', 'B'])
337
+ })
338
+
339
+ it('strips fallbacks from a realistic page fragment', () => {
340
+ const source = `---
341
+ import { getSection } from 'setzkasten:content'
342
+ const skData = getSection('_page_docs_license')
343
+ ---
344
+ <h1 data-sk-field="_page_docs_license.heading" set:html={skData?.heading ?? 'Lizenz'}></h1>
345
+ <p data-sk-field="_page_docs_license.description" set:html={skData?.description ?? 'Community License.'}></p>
346
+ {(skData?.items ?? ['Eintrag A', 'Eintrag B']).map((item, _i) => (
347
+ <li data-sk-field={\`_page_docs_license.items.\${_i}\`}>
348
+ <span set:html={item}></span>
349
+ </li>
350
+ ))}`
351
+ const { source: result, fallbacks } = stripTemplateFallbacks(source)
352
+ expect(result).toContain(`set:html={skData?.heading}`)
353
+ expect(result).toContain(`set:html={skData?.description}`)
354
+ expect(result).toContain(`(skData?.items ?? []).map(`)
355
+ expect(result).not.toContain(`?? 'Lizenz'`)
356
+ expect(result).not.toContain(`?? 'Community License.'`)
357
+ expect(fallbacks['heading']).toBe('Lizenz')
358
+ expect(fallbacks['description']).toBe('Community License.')
359
+ expect(fallbacks['items']).toEqual(['Eintrag A', 'Eintrag B'])
360
+ })
361
+
362
+ it('preserves mailto link in backtick fallback', () => {
363
+ // Bug: <a href="mailto:"> was stripped from JSON on adoption because the merge
364
+ // logic wrote plain-text defaultValue first, then discarded the richer HTML fallback.
365
+ const source = [
366
+ `<div data-sk-field="_page.callout" set:html={skData?.callout ?? \`Für kommerzielle Lizenzen: <a href="mailto:hello@example.com" class="text-sk-accent">hello@example.com</a> — wir finden ein passendes Modell.\`}></div>`,
367
+ ].join('\n')
368
+ const { source: stripped, fallbacks } = stripTemplateFallbacks(source)
369
+ expect(stripped).toContain('set:html={skData?.callout}')
370
+ expect(fallbacks['callout']).toMatch(/href="mailto:hello@example\.com"/)
371
+ expect(fallbacks['callout']).toMatch(/<a /)
372
+ })
373
+ })
374
+
375
+ // ---------------------------------------------------------------------------
376
+ // Merge logic: HTML fallback must win over plain-text defaultValue
377
+ //
378
+ // Bug: buildSectionData wrote plain-text defaultValue to sectionData first.
379
+ // stripTemplateFallbacks then found richer HTML (e.g. with <a href="mailto:">),
380
+ // but the merge condition "JSON wins if already set" discarded it.
381
+ // Fix: HTML extracted from template wins over plain-text initial value.
382
+ // ---------------------------------------------------------------------------
383
+ describe('HTML fallback wins over plain-text default in sectionData merge', () => {
384
+ // Mirror the merge logic from init-add-section.ts
385
+ function mergeFallbacks(
386
+ sectionData: Record<string, unknown>,
387
+ fallbacks: Record<string, unknown>,
388
+ ): Record<string, unknown> {
389
+ const result = { ...sectionData }
390
+ for (const [key, value] of Object.entries(fallbacks)) {
391
+ const existing = result[key]
392
+ const existingIsPlain = typeof existing === 'string' && !/<[a-z]/.test(existing)
393
+ const newIsHtml = typeof value === 'string' && /<[a-z]/.test(value as string)
394
+ if (!(key in result) || existing === '' || existing === null || (existingIsPlain && newIsHtml)) {
395
+ result[key] = value
396
+ }
397
+ }
398
+ return result
399
+ }
400
+
401
+ it('HTML from template overwrites plain-text defaultValue', () => {
402
+ const sectionData = {
403
+ callout: 'Für kommerzielle Lizenzen: hello@example.com — text.',
404
+ }
405
+ const fallbacks = {
406
+ callout: 'Für kommerzielle Lizenzen: <a href="mailto:hello@example.com">hello@example.com</a> — text.',
407
+ }
408
+ const result = mergeFallbacks(sectionData, fallbacks)
409
+ expect(result['callout']).toMatch(/href="mailto:/)
410
+ expect(result['callout']).toMatch(/<a /)
411
+ })
412
+
413
+ it('does NOT overwrite when existing value is already HTML', () => {
414
+ const sectionData = {
415
+ callout: '<p>Existing <strong>HTML</strong> content.</p>',
416
+ }
417
+ const fallbacks = {
418
+ callout: 'Some plain text fallback.',
419
+ }
420
+ const result = mergeFallbacks(sectionData, fallbacks)
421
+ expect(result['callout']).toContain('<strong>')
422
+ })
423
+
424
+ it('does NOT overwrite when existing HTML is richer than fallback HTML', () => {
425
+ const sectionData = {
426
+ callout: '<p>Bearbeiteter <a href="mailto:x@y.com">Link</a> Text.</p>',
427
+ }
428
+ const fallbacks = {
429
+ callout: '<p>Original <a href="mailto:x@y.com">Link</a> Text.</p>',
430
+ }
431
+ const result = mergeFallbacks(sectionData, fallbacks)
432
+ expect(result['callout']).toContain('Bearbeiteter')
433
+ })
434
+ })