@setzkasten-cms/astro-admin 0.8.0 → 1.1.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 (68) hide show
  1. package/package.json +16 -6
  2. package/src/admin-page.astro +1 -1
  3. package/src/api-routes/__tests__/auth-guard.test.ts +134 -0
  4. package/src/api-routes/__tests__/github-token-for-request.test.ts +112 -0
  5. package/src/api-routes/__tests__/github-token.test.ts +78 -0
  6. package/src/api-routes/__tests__/global-config-theme.test.ts +71 -0
  7. package/src/api-routes/__tests__/init-scan-page-resolve-config.test.ts +61 -0
  8. package/src/api-routes/__tests__/license-tier.test.ts +45 -0
  9. package/src/api-routes/__tests__/migrate-to-multi.test.ts +189 -0
  10. package/src/api-routes/__tests__/pages-meta-store.test.ts +179 -0
  11. package/src/api-routes/__tests__/route-registry.test.ts +120 -0
  12. package/src/api-routes/__tests__/session-cookie.test.ts +67 -0
  13. package/src/api-routes/__tests__/setup-github-app-callback.test.ts +145 -0
  14. package/src/api-routes/__tests__/setup-github-app-repos.test.ts +192 -0
  15. package/src/api-routes/__tests__/setup-github-app.test.ts +107 -0
  16. package/src/api-routes/__tests__/storage-config-for-request.test.ts +78 -0
  17. package/src/api-routes/__tests__/website-resolver-bootstrap-standalone.test.ts +85 -0
  18. package/src/api-routes/__tests__/website-resolver-bootstrap.test.ts +108 -0
  19. package/src/api-routes/__tests__/website-resolver.test.ts +123 -0
  20. package/src/api-routes/__tests__/websites-add.test.ts +305 -0
  21. package/src/api-routes/__tests__/websites-list.test.ts +112 -0
  22. package/src/api-routes/__tests__/websites-remove.test.ts +155 -0
  23. package/src/api-routes/_auth-guard.ts +134 -13
  24. package/src/api-routes/_github-token.ts +64 -0
  25. package/src/api-routes/_license-tier.ts +25 -0
  26. package/src/api-routes/_pages-meta-store.ts +134 -0
  27. package/src/api-routes/_session-cookie.ts +42 -0
  28. package/src/api-routes/_storage-config.ts +64 -4
  29. package/src/api-routes/_vercel-origin.ts +22 -0
  30. package/src/api-routes/_website-resolver.ts +243 -0
  31. package/src/api-routes/_websites-store.ts +120 -0
  32. package/src/api-routes/asset-proxy.ts +6 -4
  33. package/src/api-routes/auth-callback.ts +6 -7
  34. package/src/api-routes/auth-logout.ts +5 -1
  35. package/src/api-routes/auth-setzkasten-login.ts +21 -10
  36. package/src/api-routes/catalog-add.ts +9 -5
  37. package/src/api-routes/catalog-export.ts +8 -4
  38. package/src/api-routes/config.ts +12 -5
  39. package/src/api-routes/editors.ts +79 -10
  40. package/src/api-routes/github-proxy.ts +5 -5
  41. package/src/api-routes/global-config.ts +23 -6
  42. package/src/api-routes/init-add-section.ts +13 -5
  43. package/src/api-routes/init-apply.ts +5 -3
  44. package/src/api-routes/init-migrate.ts +7 -5
  45. package/src/api-routes/init-scan-page.ts +26 -6
  46. package/src/api-routes/init-scan.ts +5 -3
  47. package/src/api-routes/migrate-to-multi.ts +255 -0
  48. package/src/api-routes/pages.ts +118 -4
  49. package/src/api-routes/section-add.ts +15 -5
  50. package/src/api-routes/section-commit-pending.ts +18 -5
  51. package/src/api-routes/section-delete.ts +15 -5
  52. package/src/api-routes/section-duplicate.ts +15 -5
  53. package/src/api-routes/section-prepare-copy.ts +15 -4
  54. package/src/api-routes/section-prepare.ts +9 -5
  55. package/src/api-routes/setup-github-app-bounce.ts +52 -0
  56. package/src/api-routes/setup-github-app-branches.ts +63 -0
  57. package/src/api-routes/setup-github-app-callback.ts +53 -0
  58. package/src/api-routes/setup-github-app-installed.ts +44 -0
  59. package/src/api-routes/setup-github-app-repos.ts +46 -0
  60. package/src/api-routes/setup-github-app.ts +58 -0
  61. package/src/api-routes/updater-register.ts +6 -23
  62. package/src/api-routes/updater-transfer.ts +1 -12
  63. package/src/api-routes/websites-add.ts +113 -0
  64. package/src/api-routes/websites-list.ts +40 -0
  65. package/src/api-routes/websites-remove.ts +74 -0
  66. package/src/init/__tests__/patcher-mixed-content-wrapper.test.ts +90 -0
  67. package/src/init/template-patcher-v2.ts +33 -0
  68. package/LICENSE +0 -37
@@ -0,0 +1,74 @@
1
+ import { removeWebsiteFromRegistry } from '@setzkasten-cms/core'
2
+ import type { APIRoute } from 'astro'
3
+ import { requireAdmin } from './_auth-guard'
4
+ import { resolveConfigRepoToken } from './_github-token'
5
+ import {
6
+ readWebsitesRegistryFromGitHub,
7
+ resolveConfigRepoTargetFromGlobals,
8
+ writeWebsitesRegistryToGitHub,
9
+ } from './_websites-store'
10
+
11
+ /**
12
+ * POST /api/setzkasten/websites/remove
13
+ *
14
+ * Body: { id: string }
15
+ *
16
+ * Drops the matching entry from websites.json and commits the new file.
17
+ * Admin-only — editors must not be able to detach websites from the
18
+ * registry. Returns 404 when the id is not present, 502 when GitHub I/O
19
+ * fails.
20
+ */
21
+ export const POST: APIRoute = async ({ request, cookies }) => {
22
+ const denied = requireAdmin(cookies.get('setzkasten_session')?.value)
23
+ if (denied) return denied
24
+
25
+ let body: { id?: string } = {}
26
+ try {
27
+ body = (await request.json()) as { id?: string }
28
+ } catch {
29
+ return new Response(JSON.stringify({ error: 'Invalid JSON body' }), { status: 400 })
30
+ }
31
+ if (!body.id || typeof body.id !== 'string') {
32
+ return new Response(JSON.stringify({ error: 'Missing "id" in body' }), { status: 400 })
33
+ }
34
+
35
+ const tokenResult = await resolveConfigRepoToken()
36
+ if (!tokenResult.ok) {
37
+ return new Response(JSON.stringify({ error: tokenResult.error.message }), { status: 500 })
38
+ }
39
+
40
+ const target = resolveConfigRepoTargetFromGlobals(tokenResult.value)
41
+ if (!target) {
42
+ return new Response(
43
+ JSON.stringify({
44
+ error: 'Multi-mode storage not configured (storage.kind must be "multi").',
45
+ }),
46
+ { status: 400 },
47
+ )
48
+ }
49
+
50
+ const current = await readWebsitesRegistryFromGitHub(target)
51
+ if (!current.ok) {
52
+ return new Response(JSON.stringify({ error: current.error.message }), { status: 502 })
53
+ }
54
+
55
+ const next = removeWebsiteFromRegistry(current.value.registry, body.id)
56
+ if (!next.ok) {
57
+ return new Response(JSON.stringify({ error: next.error.message }), { status: 404 })
58
+ }
59
+
60
+ const written = await writeWebsitesRegistryToGitHub(
61
+ target,
62
+ next.value,
63
+ current.value.sha,
64
+ `chore(websites): remove "${body.id}" from registry`,
65
+ )
66
+ if (!written.ok) {
67
+ return new Response(JSON.stringify({ error: written.error.message }), { status: 502 })
68
+ }
69
+
70
+ return new Response(JSON.stringify({ ok: true, websites: next.value.websites }), {
71
+ status: 200,
72
+ headers: { 'Content-Type': 'application/json' },
73
+ })
74
+ }
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Patcher: mixed-content RTE wrapper conversion.
3
+ *
4
+ * Bug: Adopting a page like Impressum/Datenschutz where a `<p>` contains
5
+ * text + <br /> tags (e.g. address blocks) leaves the `<p>` as the wrapper
6
+ * with `set:html={skData?.description ?? `…`}`. When the field is later
7
+ * edited via the inline RTE, TipTap's getHTML() returns `<p>…</p>`-wrapped
8
+ * HTML — which then gets injected into the existing `<p data-sk-field>`,
9
+ * producing `<p><p>…</p></p>` at render time. HTML5 forbids `<p>` inside
10
+ * `<p>`, so the browser auto-closes the outer `<p>` immediately:
11
+ *
12
+ * <p data-sk-field="…"></p> ← empty! visible as a tiny outline box
13
+ * <p>LILAPIXEL<br>…</p> ← the actual content, unbound
14
+ *
15
+ * Fix: when patchMixedContentField wraps a phrasing-only element (`<p>`,
16
+ * `<span>`), the wrapper is converted to `<div>`. `<div>` is flow content
17
+ * and accepts arbitrary HTML children (including `<p>`).
18
+ */
19
+
20
+ import { describe, it, expect } from 'vitest'
21
+ import { patchTemplateForFields } from '../../init/template-patcher-v2'
22
+ import type { PatchField } from '../../init/template-patcher-v2'
23
+
24
+ const IMPRESSUM_SECTION = `---
25
+ import BaseLayout from '../layouts/BaseLayout.astro';
26
+ ---
27
+
28
+ <BaseLayout>
29
+ <div class="mx-auto w-full max-w-3xl">
30
+ <h1 class="mb-10 text-3xl font-bold">Impressum</h1>
31
+ <section class="mb-8">
32
+ <h2 class="mb-3 text-xl font-semibold">Angaben gemäß § 5 DDG</h2>
33
+ <p class="text-sk-slate leading-relaxed">
34
+ LILAPIXEL<br />
35
+ Birgit Soring<br />
36
+ Le-Corbusier-Str. 31b<br />
37
+ 26127 Oldenburg
38
+ </p>
39
+ </section>
40
+ </div>
41
+ </BaseLayout>
42
+ `
43
+
44
+ describe('patchMixedContentField — phrasing wrapper conversion', () => {
45
+ const fields: PatchField[] = [
46
+ { key: 'heading', type: 'text', defaultValue: 'Impressum' },
47
+ { key: 'heading2', type: 'text', defaultValue: 'Angaben gemäß § 5 DDG' },
48
+ {
49
+ key: 'description',
50
+ type: 'text',
51
+ defaultValue: 'LILAPIXEL Birgit Soring Le-Corbusier-Str. 31b 26127 Oldenburg',
52
+ },
53
+ ]
54
+
55
+ it('converts a <p> mixed-content wrapper to <div> so RTE-injected <p> children remain valid', async () => {
56
+ const patched = await patchTemplateForFields(IMPRESSUM_SECTION, '_page_impressum', fields, [], { mode: 'page' })
57
+
58
+ // The patched description binding must NOT live on a <p> element —
59
+ // otherwise an RTE-edited value of "<p>…</p>" produces nested <p>'s.
60
+ expect(patched).not.toMatch(/<p[^>]*data-sk-field="_page_impressum\.description"/)
61
+
62
+ // Instead, the wrapper should be a <div> that retains the original classes.
63
+ expect(patched).toMatch(/<div[^>]*class="text-sk-slate leading-relaxed"[^>]*data-sk-field="_page_impressum\.description"/)
64
+ expect(patched).toMatch(/data-sk-field="_page_impressum\.description"[^>]*set:html=\{skData\?\.description/)
65
+
66
+ // The closing tag must match (no </p> for this binding's wrapper).
67
+ // A simple sanity check: the <div ... data-sk-field="…description"> must be followed by </div>.
68
+ const openMatch = patched.match(/<div[^>]*data-sk-field="_page_impressum\.description"[^>]*>/)
69
+ expect(openMatch).toBeTruthy()
70
+ const openIdx = patched.indexOf(openMatch![0])
71
+ const afterOpen = patched.slice(openIdx + openMatch![0].length)
72
+ // The next closing tag for this wrapper must be </div>, not </p>.
73
+ expect(afterOpen).toMatch(/^[\s\S]*?<\/div>/)
74
+ })
75
+
76
+ it('does NOT convert headings — h2 stays h2 even if it had inline content', async () => {
77
+ // Headings rarely host RTE content but must remain semantically intact.
78
+ // patchTextField (not patchMixedContentField) handles plain text headings,
79
+ // so the <h2> should keep its tag name.
80
+ const patched = await patchTemplateForFields(IMPRESSUM_SECTION, '_page_impressum', fields, [], { mode: 'page' })
81
+ expect(patched).toMatch(/<h2[^>]*data-sk-field="_page_impressum\.heading2"/)
82
+ })
83
+
84
+ it('keeps the original <p> classes on the converted <div> wrapper', async () => {
85
+ const patched = await patchTemplateForFields(IMPRESSUM_SECTION, '_page_impressum', fields, [], { mode: 'page' })
86
+ // The Tailwind classes from the original <p class="text-sk-slate leading-relaxed">
87
+ // must be preserved on the new <div>.
88
+ expect(patched).toContain('text-sk-slate leading-relaxed')
89
+ })
90
+ })
@@ -556,10 +556,28 @@ function getElementTextContent(node: AstNode): string {
556
556
  return (node.children ?? []).map(getElementTextContent).join('')
557
557
  }
558
558
 
559
+ /**
560
+ * HTML elements whose content model only allows phrasing content (no block-level
561
+ * children, especially no nested <p>). When such an element is selected as the
562
+ * RTE wrapper, a TipTap-edited value of `<p>…</p>` would be injected via set:html,
563
+ * producing invalid markup like `<p><p>…</p></p>`. The browser auto-closes the
564
+ * outer wrapper immediately, leaving the data-sk-field element empty.
565
+ *
566
+ * Patcher converts these wrappers to <div> (flow content, accepts everything).
567
+ * Headings (<h1>-<h6>) are intentionally not in this list — they are handled
568
+ * by patchTextField, not patchMixedContentField, and converting them would
569
+ * silently drop semantic meaning.
570
+ */
571
+ const PHRASING_ONLY_RTE_WRAPPERS = new Set(['p', 'span'])
572
+
559
573
  /**
560
574
  * Patch a mixed-content element as a rich text field.
561
575
  * Adds data-sk-field + set:html to the element, keeping original HTML as fallback.
562
576
  * The live editor uses the RTE (contentEditable) for this field.
577
+ *
578
+ * If the wrapper is a phrasing-content-only tag (p, span), it is converted to
579
+ * <div> so that future RTE-injected block content (e.g. TipTap's <p>…</p>)
580
+ * remains valid HTML.
563
581
  */
564
582
  function patchMixedContentField(
565
583
  source: string,
@@ -614,6 +632,21 @@ function patchMixedContentField(
614
632
  deleteCount: closeIdx - innerStart,
615
633
  insert: '',
616
634
  })
635
+
636
+ // Phrasing-only wrappers cannot host RTE-injected block content (e.g. <p>).
637
+ // Rewrite the opening + closing tag to <div> while keeping all attributes.
638
+ if (PHRASING_ONLY_RTE_WRAPPERS.has(tagName)) {
639
+ edits.push({
640
+ offset: tagIdx + 1, // position of the tag-name char after '<'
641
+ deleteCount: tagName.length,
642
+ insert: 'div',
643
+ })
644
+ edits.push({
645
+ offset: closeIdx + 2, // position of the tag-name char after '</'
646
+ deleteCount: tagName.length,
647
+ insert: 'div',
648
+ })
649
+ }
617
650
  }
618
651
 
619
652
  /**
package/LICENSE DELETED
@@ -1,37 +0,0 @@
1
- Setzkasten Community License
2
-
3
- Copyright (c) 2026 Lilapixel
4
-
5
- Permission is hereby granted, free of charge, to any person obtaining a copy
6
- of this software and associated documentation files (the "Software"), to use,
7
- copy, modify, merge, publish, and distribute the Software, subject to the
8
- following conditions:
9
-
10
- 1. The above copyright notice and this permission notice shall be included in
11
- all copies or substantial portions of the Software.
12
-
13
- 2. The Software may not be used for commercial purposes without a separate
14
- commercial license from the copyright holder. "Commercial purposes" means
15
- any use of the Software that is primarily intended for or directed toward
16
- commercial advantage or monetary compensation. This includes, but is not
17
- limited to:
18
- - Using the Software to manage content for a commercial website or product
19
- - Offering the Software as part of a paid service
20
- - Using the Software within a for-profit organization
21
-
22
- 3. Non-commercial use is permitted without restriction. This includes:
23
- - Personal projects
24
- - Open source projects
25
- - Educational and academic use
26
- - Non-profit organizations
27
-
28
- 4. A commercial license ("Enterprise License") may be obtained by contacting
29
- Lilapixel at hello@lilapixel.de.
30
-
31
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
32
- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
33
- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
34
- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
35
- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
36
- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
37
- SOFTWARE.