@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,474 @@
1
+ /**
2
+ * Tests: hardcoded <li> items in docs pages.
3
+ *
4
+ * Bug: When a docs page has <ul>/<li> elements with hardcoded text,
5
+ * those items are invisible to the CMS editor after adoption — the analyzer
6
+ * detected headings and paragraphs but missed static list item content.
7
+ *
8
+ * Covers:
9
+ * 1. Analyzer: detects <li> text content as array fields
10
+ * 2. Analyzer: total field count includes list items
11
+ * 3. Patcher: after patching, no <li> contains hardcoded text (only CMS bindings)
12
+ * 4. Patcher: list items are driven by .map() over CMS array
13
+ * 5. Regression: PageWithListItemsAdopted is already clean (all CMS-bound)
14
+ * 6. Regression: real docs pages have no hardcoded <li> text after fix
15
+ */
16
+
17
+ import { describe, it, expect, beforeAll } from 'vitest'
18
+ import { readFileSync } from 'node:fs'
19
+ import { join, resolve } from 'node:path'
20
+ import { analyzeAstroSection } from '../astro-section-analyzer-v2'
21
+ import { patchTemplateForFields } from '../template-patcher-v2'
22
+
23
+ const SECTIONS_DIR = resolve(import.meta.dirname!, '..', '..', '..', '..', '..', 'test-sections')
24
+
25
+ // ---------------------------------------------------------------------------
26
+ // Helper: find hardcoded <li> text — a <span> inside <li> with literal text,
27
+ // no set:html attribute, and not inside a {(...).map(...)} expression.
28
+ // After a correct adoption, this should return 0 matches.
29
+ // ---------------------------------------------------------------------------
30
+ function findHardcodedListItems(source: string): RegExpMatchArray[] {
31
+ // Match <span> that has direct text (no set:html, no {expr}), inside a <li>
32
+ // We look for: <span> followed by a letter (not { or <), then text, then </span>
33
+ const pattern = /<li[\s\S]{0,300}?<span(?![^>]*set:html)(?![^>]*data-sk-field)[^>]*>([A-Za-zÄÖÜäöüß][^<{]*)<\/span>/g
34
+ return [...source.matchAll(pattern)]
35
+ }
36
+
37
+ // ---------------------------------------------------------------------------
38
+ // 1. Fixture: PageWithHardcodedList (the bug scenario)
39
+ // The analyzer SHOULD find the list item texts as array fields.
40
+ // ---------------------------------------------------------------------------
41
+ describe('PageWithHardcodedList — analyzer detects list items', () => {
42
+ let source: string
43
+ let section: Awaited<ReturnType<typeof analyzeAstroSection>>
44
+
45
+ beforeAll(async () => {
46
+ source = readFileSync(join(SECTIONS_DIR, 'PageWithHardcodedList.astro'), 'utf-8')
47
+ section = await analyzeAstroSection(
48
+ source,
49
+ '_page_docs_admin',
50
+ 'adminPage',
51
+ 'src/pages/docs/admin.astro',
52
+ { mode: 'page' },
53
+ )
54
+ })
55
+
56
+ it('should detect fields (page has visible content)', () => {
57
+ expect(section.fields.length).toBeGreaterThan(0)
58
+ })
59
+
60
+ it('should detect the h2 headings as text fields', () => {
61
+ const keys = section.fields.map(f => f.key)
62
+ // The page has "Page Builder", "Section-Editor", "Collections" as <h2>
63
+ const hasHeadings = keys.some(k => k.includes('heading') || k.includes('pageBuild') || k.includes('section'))
64
+ expect(hasHeadings, 'Should detect h2 headings').toBe(true)
65
+ })
66
+
67
+ it('should detect list item content as array fields', () => {
68
+ // List items should appear either as array fields or individual text fields
69
+ const arrayFields = section.fields.filter(f => f.type === 'array')
70
+ const allFields = section.fields
71
+
72
+ // Either the list items are grouped as arrays, or they appear as individual text fields
73
+ // In both cases, the TOTAL field count must be higher than just headings+descriptions
74
+ // (3 headings + 3 descriptions = 6; with list items: should be > 6)
75
+ expect(allFields.length).toBeGreaterThan(6)
76
+ })
77
+
78
+ it('should find list item default values', () => {
79
+ const allDefaultValues = section.fields
80
+ .flatMap(f => {
81
+ if (f.type === 'array' && Array.isArray(f.defaultValue)) return f.defaultValue as string[]
82
+ return typeof f.defaultValue === 'string' ? [f.defaultValue] : []
83
+ })
84
+
85
+ const hasListContent = allDefaultValues.some(v =>
86
+ typeof v === 'string' && (
87
+ v.includes('adoptieren') ||
88
+ v.includes('Textfelder') ||
89
+ v.includes('Eintraege')
90
+ ),
91
+ )
92
+ expect(hasListContent, 'List item text should appear in detected field default values').toBe(true)
93
+ })
94
+ })
95
+
96
+ // ---------------------------------------------------------------------------
97
+ // 2. Patcher: after patching PageWithHardcodedList, no hardcoded <li> text remains
98
+ // ---------------------------------------------------------------------------
99
+ describe('PageWithHardcodedList — patcher eliminates hardcoded list items', () => {
100
+ let patched: string
101
+
102
+ beforeAll(async () => {
103
+ const source = readFileSync(join(SECTIONS_DIR, 'PageWithHardcodedList.astro'), 'utf-8')
104
+ const section = await analyzeAstroSection(
105
+ source,
106
+ '_page_docs_admin',
107
+ 'adminPage',
108
+ 'src/pages/docs/admin.astro',
109
+ { mode: 'page' },
110
+ )
111
+ const groups = (section as any)._analyzerResult?.repeatedGroups ?? []
112
+ patched = await patchTemplateForFields(
113
+ source,
114
+ '_page_docs_admin',
115
+ section.fields,
116
+ groups,
117
+ { mode: 'page' },
118
+ )
119
+ })
120
+
121
+ it('should not contain hardcoded <li> text after patching', () => {
122
+ const hardcoded = findHardcodedListItems(patched)
123
+ expect(
124
+ hardcoded,
125
+ `Hardcoded list items found after patching:\n${hardcoded.map(m => ` "${m[1]}"`).join('\n')}`,
126
+ ).toHaveLength(0)
127
+ })
128
+
129
+ it('should contain data-sk-field on list items', () => {
130
+ expect(patched).toContain('data-sk-field')
131
+ // List items should be driven by .map() with indexed data-sk-field (template literal form)
132
+ expect(patched).toMatch(/data-sk-field=\{`[^`]*\.\$\{_i\}`\}|data-sk-field="[^"]*\.\d+"/)
133
+ })
134
+
135
+ it('should use skData array for list items', () => {
136
+ // Expect something like: (skData?.items ?? [...]).map(
137
+ expect(patched).toMatch(/skData\?\.items.*map|\.map.*skData\?\./)
138
+ })
139
+
140
+ it('should inject getSection in page mode', () => {
141
+ expect(patched).toContain("import { getSection } from 'setzkasten:content'")
142
+ })
143
+ })
144
+
145
+ // ---------------------------------------------------------------------------
146
+ // 3b. Partially adopted page with static list: patcher fixes the remaining items
147
+ // This covers the real-world case of admin.astro / license.astro after a
148
+ // first adoption pass that bound headings+descriptions but missed <ul>/<li>.
149
+ // ---------------------------------------------------------------------------
150
+ describe('PagePartiallyAdoptedWithList — patcher fixes remaining static list', () => {
151
+ let patched: string
152
+
153
+ beforeAll(async () => {
154
+ const source = readFileSync(join(SECTIONS_DIR, 'PagePartiallyAdoptedWithList.astro'), 'utf-8')
155
+ const section = await analyzeAstroSection(
156
+ source,
157
+ '_page_docs_partial',
158
+ 'partialPage',
159
+ 'src/pages/docs/partial.astro',
160
+ { mode: 'page' },
161
+ )
162
+ const groups = (section as any)._analyzerResult?.repeatedGroups ?? []
163
+ patched = await patchTemplateForFields(
164
+ source,
165
+ '_page_docs_partial',
166
+ section.fields,
167
+ groups,
168
+ { mode: 'page' },
169
+ )
170
+ })
171
+
172
+ it('should detect the static list as an array field', async () => {
173
+ const source = readFileSync(join(SECTIONS_DIR, 'PagePartiallyAdoptedWithList.astro'), 'utf-8')
174
+ const section = await analyzeAstroSection(
175
+ source, '_page_docs_partial', 'partialPage', 'src/pages/docs/partial.astro', { mode: 'page' },
176
+ )
177
+ const arrayFields = section.fields.filter(f => f.type === 'array')
178
+ expect(arrayFields.length, 'Should detect at least one array field').toBeGreaterThanOrEqual(1)
179
+ })
180
+
181
+ it('should have no hardcoded <li> text after patching', () => {
182
+ const hardcoded = findHardcodedListItems(patched)
183
+ expect(
184
+ hardcoded,
185
+ `Hardcoded list items remain after patching partially-adopted page:\n${hardcoded.map(m => ` "${m[1]}"`).join('\n')}`,
186
+ ).toHaveLength(0)
187
+ })
188
+
189
+ it('should use .map() for the list after patching', () => {
190
+ expect(patched).toMatch(/\.map\(\s*\(item/)
191
+ })
192
+
193
+ it('should keep existing data-sk-field bindings intact', () => {
194
+ // Existing CMS bindings (headings, descriptions) must not be broken
195
+ expect(patched).toContain('data-sk-field="_page_docs_partial.heading"')
196
+ expect(patched).toContain('data-sk-field="_page_docs_partial.description"')
197
+ })
198
+ })
199
+
200
+ // ---------------------------------------------------------------------------
201
+ // 3. Regression: PageWithListItemsAdopted is already clean
202
+ // The "good" fixture should have zero hardcoded <li> items.
203
+ // ---------------------------------------------------------------------------
204
+ describe('PageWithListItemsAdopted — already clean (regression baseline)', () => {
205
+ it('should have no hardcoded <li> text', () => {
206
+ const source = readFileSync(join(SECTIONS_DIR, 'PageWithListItemsAdopted.astro'), 'utf-8')
207
+ const hardcoded = findHardcodedListItems(source)
208
+ expect(
209
+ hardcoded,
210
+ `Adopted fixture should have no hardcoded list items, found:\n${hardcoded.map(m => ` "${m[1]}"`).join('\n')}`,
211
+ ).toHaveLength(0)
212
+ })
213
+
214
+ it('should have .map() driven lists', () => {
215
+ const source = readFileSync(join(SECTIONS_DIR, 'PageWithListItemsAdopted.astro'), 'utf-8')
216
+ // Each <ul> should be driven by a .map() over a CMS array
217
+ const mapCount = (source.match(/\.map\(\s*\(item/g) ?? []).length
218
+ expect(mapCount).toBeGreaterThanOrEqual(3) // three lists
219
+ })
220
+
221
+ it('should have data-sk-field on every list item', () => {
222
+ const source = readFileSync(join(SECTIONS_DIR, 'PageWithListItemsAdopted.astro'), 'utf-8')
223
+ expect(source).toMatch(/data-sk-field=\{`_page_docs_admin\.items/)
224
+ })
225
+ })
226
+
227
+ // ---------------------------------------------------------------------------
228
+ // 4. PageWithFormattedList — <li> items with <strong> inside spans
229
+ // The analyzer must detect formatting, store innerHTML as default values,
230
+ // and set formatting: true on the array item type.
231
+ // The patcher must replace the content span with set:html={item}.
232
+ // ---------------------------------------------------------------------------
233
+ describe('PageWithFormattedList — analyzer detects formatting in list items', () => {
234
+ let source: string
235
+ let section: Awaited<ReturnType<typeof analyzeAstroSection>>
236
+
237
+ beforeAll(async () => {
238
+ source = readFileSync(join(SECTIONS_DIR, 'PageWithFormattedList.astro'), 'utf-8')
239
+ section = await analyzeAstroSection(
240
+ source,
241
+ '_page_formatted',
242
+ 'formattedPage',
243
+ 'src/pages/formatted.astro',
244
+ { mode: 'page' },
245
+ )
246
+ })
247
+
248
+ it('should detect the list as an array field', () => {
249
+ const arrayFields = section.fields.filter(f => f.type === 'array')
250
+ expect(arrayFields.length).toBeGreaterThanOrEqual(1)
251
+ })
252
+
253
+ it('should set formatting: true on the array item type', () => {
254
+ const arrayField = section.fields.find(f => f.type === 'array')
255
+ expect(arrayField).toBeDefined()
256
+ expect((arrayField?.options as any)?.arrayItem?.formatting).toBe(true)
257
+ })
258
+
259
+ it('should store innerHTML (with <strong>) as default values', () => {
260
+ const arrayField = section.fields.find(f => f.type === 'array')
261
+ const items = arrayField?.defaultValue as string[] | undefined
262
+ expect(Array.isArray(items)).toBe(true)
263
+ const hasStrong = (items ?? []).some(v => typeof v === 'string' && v.includes('<strong'))
264
+ expect(hasStrong, 'Default values should contain <strong> HTML').toBe(true)
265
+ })
266
+ })
267
+
268
+ describe('PageWithFormattedList — patcher replaces content span with set:html', () => {
269
+ let patched: string
270
+
271
+ beforeAll(async () => {
272
+ const source = readFileSync(join(SECTIONS_DIR, 'PageWithFormattedList.astro'), 'utf-8')
273
+ const section = await analyzeAstroSection(
274
+ source,
275
+ '_page_formatted',
276
+ 'formattedPage',
277
+ 'src/pages/formatted.astro',
278
+ { mode: 'page' },
279
+ )
280
+ const groups = (section as any)._analyzerResult?.repeatedGroups ?? []
281
+ patched = await patchTemplateForFields(
282
+ source,
283
+ '_page_formatted',
284
+ section.fields,
285
+ groups,
286
+ { mode: 'page' },
287
+ )
288
+ })
289
+
290
+ it('should inject set:html={item} on the content span', () => {
291
+ expect(patched).toContain('set:html={item}')
292
+ })
293
+
294
+ it('should not contain hardcoded <strong> text in the .map() template', () => {
295
+ // After patching, the <li> template inside .map() must not have hardcoded <strong>.
296
+ // The fallback array (between ?? [...]) may contain HTML — that's fine (it's data).
297
+ // Strip JS template literals and {expressions} before checking.
298
+ const withoutTemplateLiterals = patched.replace(/`[^`]*`/g, '""')
299
+ const withoutExpressions = withoutTemplateLiterals.replace(/\{[^}]*\}/g, '{}')
300
+ const strongInMarkup = withoutExpressions.match(/<strong[^>]*>[^<{]+<\/strong>/g) ?? []
301
+ expect(strongInMarkup, `Hardcoded <strong> in template: ${strongInMarkup.join(', ')}`).toHaveLength(0)
302
+ })
303
+
304
+ it('should add data-sk-field to list items', () => {
305
+ expect(patched).toMatch(/data-sk-field=\{`_page_formatted\.\w+\.\$\{_i\}`\}/)
306
+ })
307
+
308
+ it('should use .map() for the list', () => {
309
+ expect(patched).toMatch(/\.map\(\s*\(item/)
310
+ })
311
+ })
312
+
313
+ // ---------------------------------------------------------------------------
314
+ // 6. PageWithCalloutDiv — <div> with mixed content (text + inline <a>)
315
+ // The analyzer must NOT skip the callout div just because it contains <a>.
316
+ // Mixed-text divs (direct text nodes + inline links) must be detected.
317
+ // ---------------------------------------------------------------------------
318
+ describe('PageWithCalloutDiv — callout div with inline link detected as description field', () => {
319
+ let section: Awaited<ReturnType<typeof analyzeAstroSection>>
320
+
321
+ beforeAll(async () => {
322
+ const source = readFileSync(join(SECTIONS_DIR, 'PageWithCalloutDiv.astro'), 'utf-8')
323
+ section = await analyzeAstroSection(
324
+ source,
325
+ '_page_callout_div',
326
+ 'calloutDivPage',
327
+ 'src/pages/callout-div.astro',
328
+ { mode: 'page' },
329
+ )
330
+ })
331
+
332
+ it('detects the callout div as a description field (not silently dropped)', () => {
333
+ // The fixture has 3 already-bound description fields (description, description2, description3).
334
+ // The callout div must get the next available key: description4.
335
+ // Bug: descCount started at 0, so key was 'description' → collision → skipped.
336
+ // Fix: descCount is initialized from the count of already-used description* keys.
337
+ const calloutField = section.fields.find(f => f.key === 'description4')
338
+ expect(calloutField, 'description4 field must be detected from callout div (not dropped due to key collision)').toBeDefined()
339
+ })
340
+
341
+ it('sets formatting: true on the callout field (contains inline <a>)', () => {
342
+ const calloutField = section.fields.find(f => f.key === 'description4')
343
+ expect((calloutField?.options as any)?.formatting).toBe(true)
344
+ })
345
+
346
+ it('extracts the text content as defaultValue', () => {
347
+ const calloutField = section.fields.find(f => f.key === 'description4')
348
+ expect(typeof calloutField?.defaultValue).toBe('string')
349
+ expect((calloutField?.defaultValue as string)).toMatch(/kommerzielle Lizenzen/)
350
+ })
351
+
352
+ it('still detects heading from data-sk-field (Section 0)', () => {
353
+ const headingField = section.fields.find(f => f.key === 'heading')
354
+ expect(headingField, 'heading must be detected via Section 0').toBeDefined()
355
+ expect(headingField?.defaultValue).toBe('Lizenz')
356
+ })
357
+ })
358
+
359
+ // Note: regression coverage for real docs pages is intentionally omitted here.
360
+ // Real website pages can be in various adoption states (pre/post-adoption) and
361
+ // should not be coupled to unit tests. The PageWithListItemsAdopted fixture
362
+ // above serves as the stable regression baseline for the patcher pipeline.
363
+
364
+ // ---------------------------------------------------------------------------
365
+ // 5. PageWithAdoptedArrays — already-adopted page with ?? [...] fallback arrays
366
+ // The analyzer must detect array fields (not text) from .map() expressions
367
+ // that carry ?? ['item1', 'item2'] fallbacks (script-patched pages).
368
+ // ---------------------------------------------------------------------------
369
+ describe('PageWithAdoptedArrays — analyzer detects arrays from ?? [...] fallbacks', () => {
370
+ let section: Awaited<ReturnType<typeof analyzeAstroSection>>
371
+
372
+ beforeAll(async () => {
373
+ const source = readFileSync(join(SECTIONS_DIR, 'PageWithAdoptedArrays.astro'), 'utf-8')
374
+ section = await analyzeAstroSection(
375
+ source,
376
+ '_page_adopted_arrays',
377
+ 'adoptedArraysPage',
378
+ 'src/pages/adopted-arrays.astro',
379
+ { mode: 'page' },
380
+ )
381
+ })
382
+
383
+ it('detects items as type array (not text)', () => {
384
+ const itemsField = section.fields.find(f => f.key === 'items')
385
+ expect(itemsField, 'items field must exist').toBeDefined()
386
+ expect(itemsField?.type).toBe('array')
387
+ })
388
+
389
+ it('extracts fallback items as defaultValue', () => {
390
+ const itemsField = section.fields.find(f => f.key === 'items')
391
+ expect(Array.isArray(itemsField?.defaultValue)).toBe(true)
392
+ expect(itemsField?.defaultValue).toEqual(['Eintrag A', 'Eintrag B', 'Eintrag C'])
393
+ })
394
+
395
+ it('sets no formatting on plain-text array items', () => {
396
+ const itemsField = section.fields.find(f => f.key === 'items')
397
+ expect((itemsField?.options as any)?.arrayItem?.formatting).toBeFalsy()
398
+ })
399
+
400
+ it('detects items2 as type array', () => {
401
+ const items2Field = section.fields.find(f => f.key === 'items2')
402
+ expect(items2Field?.type).toBe('array')
403
+ })
404
+
405
+ it('sets formatting: true on items2 because items contain HTML', () => {
406
+ const items2Field = section.fields.find(f => f.key === 'items2')
407
+ expect((items2Field?.options as any)?.arrayItem?.formatting).toBe(true)
408
+ })
409
+
410
+ it('extracts HTML items as defaultValue for items2', () => {
411
+ const items2Field = section.fields.find(f => f.key === 'items2')
412
+ const items = items2Field?.defaultValue as string[] | undefined
413
+ expect(Array.isArray(items)).toBe(true)
414
+ expect(items?.some(v => v.includes('<strong'))).toBe(true)
415
+ })
416
+
417
+ it('detects heading with fallback value', () => {
418
+ const headingField = section.fields.find(f => f.key === 'heading')
419
+ expect(headingField?.type).toBe('text')
420
+ expect(headingField?.defaultValue).toBe('Lizenz')
421
+ })
422
+ })
423
+
424
+ // ---------------------------------------------------------------------------
425
+ // 7. PageWithHtmlFallback — Section 0 walkers must set formatting: true
426
+ // when fallback values contain HTML tags (<code>, <strong>).
427
+ //
428
+ // Bug: attribute walker and expression walker both added fields without
429
+ // formatting:true even when the ?? fallback contained HTML. This caused:
430
+ // a) generated schema to have f.text({ label: '...' }) without formatting:true
431
+ // b) MiniRTE not active → raw HTML shown in plain text input
432
+ // ---------------------------------------------------------------------------
433
+ describe('PageWithHtmlFallback — Section 0 sets formatting:true for HTML fallbacks', () => {
434
+ let section: Awaited<ReturnType<typeof analyzeAstroSection>>
435
+
436
+ beforeAll(async () => {
437
+ const source = readFileSync(join(SECTIONS_DIR, 'PageWithHtmlFallback.astro'), 'utf-8')
438
+ section = await analyzeAstroSection(
439
+ source,
440
+ '_page_html_fallback',
441
+ 'htmlFallbackPage',
442
+ 'src/pages/html-fallback.astro',
443
+ { mode: 'page' },
444
+ )
445
+ })
446
+
447
+ it('detects heading without formatting (plain text fallback)', () => {
448
+ const f = section.fields.find(field => field.key === 'heading')
449
+ expect(f, 'heading must be detected').toBeDefined()
450
+ expect((f?.options as any)?.formatting).toBeFalsy()
451
+ })
452
+
453
+ it('detects description with formatting:true (set:html attr with <code> in fallback)', () => {
454
+ const f = section.fields.find(field => field.key === 'description')
455
+ expect(f, 'description must be detected').toBeDefined()
456
+ expect((f?.options as any)?.formatting).toBe(true)
457
+ })
458
+
459
+ it('description defaultValue contains the HTML with <code>', () => {
460
+ const f = section.fields.find(field => field.key === 'description')
461
+ expect((f?.defaultValue as string)).toMatch(/<code>/)
462
+ })
463
+
464
+ it('detects note with formatting:true (expression fallback with <strong>)', () => {
465
+ const f = section.fields.find(field => field.key === 'note')
466
+ expect(f, 'note must be detected').toBeDefined()
467
+ expect((f?.options as any)?.formatting).toBe(true)
468
+ })
469
+
470
+ it('note defaultValue contains the HTML with <strong>', () => {
471
+ const f = section.fields.find(field => field.key === 'note')
472
+ expect((f?.defaultValue as string)).toMatch(/<strong>/)
473
+ })
474
+ })