@rimelight/cms 0.0.1

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 (87) hide show
  1. package/README.md +19 -0
  2. package/package.json +69 -0
  3. package/src/astro/BlockRenderer.astro +111 -0
  4. package/src/astro/PageRenderer.astro +23 -0
  5. package/src/astro/blocks/CalloutBlock.astro +78 -0
  6. package/src/astro/blocks/CardBlock.astro +43 -0
  7. package/src/astro/blocks/CardsBlock.astro +24 -0
  8. package/src/astro/blocks/CodeBlock.astro +16 -0
  9. package/src/astro/blocks/DialogueBlock.astro +23 -0
  10. package/src/astro/blocks/FileTreeBlock.astro +45 -0
  11. package/src/astro/blocks/ImageBlock.astro +17 -0
  12. package/src/astro/blocks/OrderedListBlock.astro +55 -0
  13. package/src/astro/blocks/ParagraphBlock.astro +65 -0
  14. package/src/astro/blocks/SceneBlock.astro +65 -0
  15. package/src/astro/blocks/ScriptBlock.astro +59 -0
  16. package/src/astro/blocks/SectionBlock.astro +46 -0
  17. package/src/astro/blocks/StepItemBlock.astro +25 -0
  18. package/src/astro/blocks/StepsBlock.astro +14 -0
  19. package/src/astro/blocks/TabItemBlock.astro +19 -0
  20. package/src/astro/blocks/TableBlock.astro +49 -0
  21. package/src/astro/blocks/TabsBlock.astro +67 -0
  22. package/src/astro/blocks/UnorderedListBlock.astro +55 -0
  23. package/src/auth/editor-guards.ts +148 -0
  24. package/src/auth/filter-blocks.ts +44 -0
  25. package/src/auth/filter-templates.ts +81 -0
  26. package/src/auth/permissions.ts +40 -0
  27. package/src/cache/purge.ts +37 -0
  28. package/src/client/components/RimelightBlock.tsx +487 -0
  29. package/src/client/components/RimelightBlockPicker.tsx +305 -0
  30. package/src/client/components/RimelightEditor.tsx +131 -0
  31. package/src/client/components/RimelightEditorLayout.tsx +143 -0
  32. package/src/client/components/RimelightPreview.tsx +354 -0
  33. package/src/client/components/RimelightPropertiesPanel.tsx +408 -0
  34. package/src/client/components/RimelightTemplateEditor.tsx +245 -0
  35. package/src/client/components/editors/CalloutEditor.tsx +185 -0
  36. package/src/client/components/editors/CardEditor.tsx +60 -0
  37. package/src/client/components/editors/CardsEditor.tsx +132 -0
  38. package/src/client/components/editors/CodeEditor.tsx +101 -0
  39. package/src/client/components/editors/DialogueEditor.tsx +52 -0
  40. package/src/client/components/editors/FileTreeEditor.tsx +65 -0
  41. package/src/client/components/editors/ImageEditor.tsx +70 -0
  42. package/src/client/components/editors/ParagraphEditor.tsx +314 -0
  43. package/src/client/components/editors/SceneEditor.tsx +245 -0
  44. package/src/client/components/editors/ScriptEditor.tsx +245 -0
  45. package/src/client/components/editors/SectionEditor.tsx +139 -0
  46. package/src/client/components/editors/StepItemEditor.tsx +117 -0
  47. package/src/client/components/editors/StepsEditor.tsx +105 -0
  48. package/src/client/components/editors/TabItemEditor.tsx +113 -0
  49. package/src/client/components/editors/TableEditor.tsx +181 -0
  50. package/src/client/components/editors/TabsEditor.tsx +105 -0
  51. package/src/client/components/index.ts +7 -0
  52. package/src/client/loader.ts +19 -0
  53. package/src/client/state/autosave.ts +122 -0
  54. package/src/client/state/editor-store.ts +411 -0
  55. package/src/client/state/history.ts +251 -0
  56. package/src/client/state/lock-manager.ts +89 -0
  57. package/src/client/styles/cms-editor.css +653 -0
  58. package/src/client/toast.ts +12 -0
  59. package/src/client/utils/sortable.ts +184 -0
  60. package/src/client/utils/splitpane.ts +87 -0
  61. package/src/client/utils/tree-sanitizer.ts +33 -0
  62. package/src/core/page-definitions.ts +502 -0
  63. package/src/core/registry.ts +99 -0
  64. package/src/core/template-sync.ts +75 -0
  65. package/src/core/types/blocks.ts +367 -0
  66. package/src/core/types/inline.ts +23 -0
  67. package/src/core/types/pages.ts +36 -0
  68. package/src/core/types/templates.ts +32 -0
  69. package/src/core/types/versioning.ts +44 -0
  70. package/src/core/validator.ts +28 -0
  71. package/src/env.d.ts +4 -0
  72. package/src/index.ts +47 -0
  73. package/src/markdown/index.ts +2 -0
  74. package/src/markdown/parser.ts +347 -0
  75. package/src/markdown/serializer.ts +219 -0
  76. package/src/migration.ts +155 -0
  77. package/src/schema/index.ts +8 -0
  78. package/src/schema/page_draft_locks.ts +13 -0
  79. package/src/schema/page_drafts.ts +18 -0
  80. package/src/schema/page_templates.ts +27 -0
  81. package/src/schema/page_version_approvals.ts +12 -0
  82. package/src/schema/page_version_comments.ts +14 -0
  83. package/src/schema/page_versions.ts +34 -0
  84. package/src/schema/pages.ts +42 -0
  85. package/src/schema/search.ts +23 -0
  86. package/src/search/query.ts +10 -0
  87. package/src/search/sync.ts +32 -0
@@ -0,0 +1,59 @@
1
+ ---
2
+ import type { BaseBlock } from "@rimelight/cms"
3
+
4
+ interface Props {
5
+ block: BaseBlock
6
+ locale?: string
7
+ }
8
+
9
+ const { block } = Astro.props
10
+ const { title, synopsis, characters, aPlot, bPlot, cPlot, stinger } = block.props
11
+ ---
12
+
13
+ <div class="my-6 p-4 border-l-4 border-amber-400 bg-amber-50/20 dark:bg-amber-950/10 rounded-r-lg">
14
+ <div class="mb-4">
15
+ <h3 class="text-xl font-bold text-amber-900 dark:text-amber-100">{title}</h3>
16
+ {synopsis && <p class="text-sm text-gray-600 dark:text-gray-400 mt-1 italic">{synopsis}</p>}
17
+ {characters && characters.length > 0 && (
18
+ <div class="flex flex-wrap gap-3 mt-3 text-xs">
19
+ {characters.map((char: string) => (
20
+ <span class="px-2 py-0.5 rounded-full bg-amber-100 dark:bg-amber-900/30 text-amber-800 dark:text-amber-200">
21
+ {char}
22
+ </span>
23
+ ))}
24
+ </div>
25
+ )}
26
+ </div>
27
+ {(aPlot || bPlot || cPlot) && (
28
+ <div class="grid grid-cols-1 md:grid-cols-3 gap-3 my-3">
29
+ {aPlot && (
30
+ <div class="bg-white/50 dark:bg-neutral-900/30 rounded p-2 border border-gray-200 dark:border-gray-700">
31
+ <div class="text-[10px] font-bold uppercase tracking-wider text-gray-600 dark:text-gray-400">A Plot</div>
32
+ <div class="text-sm mt-0.5">{aPlot}</div>
33
+ </div>
34
+ )}
35
+ {bPlot && (
36
+ <div class="bg-white/50 dark:bg-neutral-900/30 rounded p-2 border border-gray-200 dark:border-gray-700">
37
+ <div class="text-[10px] font-bold uppercase tracking-wider text-gray-600 dark:text-gray-400">B Plot</div>
38
+ <div class="text-sm mt-0.5">{bPlot}</div>
39
+ </div>
40
+ )}
41
+ {cPlot && (
42
+ <div class="bg-white/50 dark:bg-neutral-900/30 rounded p-2 border border-gray-200 dark:border-gray-700">
43
+ <div class="text-[10px] font-bold uppercase tracking-wider text-gray-600 dark:text-gray-400">C Plot</div>
44
+ <div class="text-sm mt-0.5">{cPlot}</div>
45
+ </div>
46
+ )}
47
+ </div>
48
+ )}
49
+ {stinger && (
50
+ <p class="text-sm text-gray-600 dark:text-gray-400 italic mt-2 border-t border-gray-200 dark:border-gray-700 pt-2">
51
+ Stinger: {stinger}
52
+ </p>
53
+ )}
54
+ {((block.children && block.children.length > 0) || (block.props?.children && block.props.children.length > 0)) && (
55
+ <div class="mt-4">
56
+ <slot />
57
+ </div>
58
+ )}
59
+ </div>
@@ -0,0 +1,46 @@
1
+ ---
2
+ import { type BaseBlock, type HeadingLevel, calculateHeadingLevel } from "../../core/types/blocks"
3
+
4
+ interface Props {
5
+ block: BaseBlock
6
+ locale?: string
7
+ sectionDepth?: number
8
+ }
9
+
10
+ const { block, sectionDepth = 0 } = Astro.props
11
+ const { title = "", description = "" } = block.props || {}
12
+
13
+ function slugify(text: string): string {
14
+ return text
15
+ .toString()
16
+ .toLowerCase()
17
+ .normalize("NFD")
18
+ .replace(/[\u0300-\u036f]/g, "")
19
+ .replace(/\s+/g, "-")
20
+ .replace(/[^\w-]+/g, "")
21
+ .replace(/-{2,}/g, "-")
22
+ .replace(/^-+/, "")
23
+ .replace(/-+$/, "")
24
+ }
25
+
26
+ const safeLevel = calculateHeadingLevel(sectionDepth)
27
+ const headingId = title ? slugify(title) : undefined
28
+
29
+ const headingClasses: Record<HeadingLevel, string> = {
30
+ 2: "text-3xl font-bold text-neutral-900 dark:text-white mt-6 mb-3 border-b border-gray-200 dark:border-gray-800 pb-2",
31
+ 3: "text-2xl font-semibold text-neutral-900 dark:text-white mt-5 mb-2.5",
32
+ 4: "text-xl font-medium text-neutral-900 dark:text-white mt-4 mb-2",
33
+ 5: "text-lg font-medium text-neutral-800 dark:text-neutral-200 mt-3 mb-1.5",
34
+ 6: "text-base font-normal text-neutral-700 dark:text-neutral-300 mt-2 mb-1"
35
+ }
36
+
37
+ const Tag = `h${safeLevel}` as any
38
+ ---
39
+
40
+ <section id={headingId} class="cms-section my-6 scroll-mt-20" data-block-id={block.id}>
41
+ {title && <Tag class={headingClasses[safeLevel] || headingClasses[2]}>{title}</Tag>}
42
+ {description && <p class="text-sm text-gray-500 dark:text-gray-400 mt-1 mb-4 italic">{description}</p>}
43
+ <div class="mt-4">
44
+ <slot />
45
+ </div>
46
+ </section>
@@ -0,0 +1,25 @@
1
+ ---
2
+ import type { BaseBlock } from "../../core/types/blocks"
3
+
4
+ interface Props {
5
+ block: BaseBlock
6
+ locale?: string
7
+ index?: number
8
+ }
9
+
10
+ const { block, index = 0 } = Astro.props
11
+ const { title = "", description = "" } = block.props || {}
12
+ ---
13
+
14
+ <div class="cms-step-item relative" data-block-id={block.id}>
15
+ <div class="absolute -left-[35px] top-0 flex size-6 items-center justify-center rounded-full bg-neutral-900 text-white dark:bg-white dark:text-neutral-900 text-xs font-bold ring-4 ring-white dark:ring-neutral-950">
16
+ {index + 1}
17
+ </div>
18
+ <div class="space-y-2">
19
+ {title && <h3 class="text-base font-semibold text-neutral-900 dark:text-white mt-0">{title}</h3>}
20
+ {description && <p class="text-sm text-neutral-600 dark:text-neutral-400 mt-1">{description}</p>}
21
+ <div class="mt-3">
22
+ <slot />
23
+ </div>
24
+ </div>
25
+ </div>
@@ -0,0 +1,14 @@
1
+ ---
2
+ import type { BaseBlock } from "../../core/types/blocks"
3
+
4
+ interface Props {
5
+ block: BaseBlock
6
+ locale?: string
7
+ }
8
+
9
+ const { block } = Astro.props
10
+ ---
11
+
12
+ <div class="cms-steps my-6 relative border-l-2 border-neutral-200 dark:border-neutral-800 ml-4 pl-6 space-y-6" data-block-id={block.id}>
13
+ <slot />
14
+ </div>
@@ -0,0 +1,19 @@
1
+ ---
2
+ import type { BaseBlock } from "../../core/types/blocks"
3
+
4
+ interface Props {
5
+ block: BaseBlock
6
+ locale?: string
7
+ index?: number
8
+ isFirst?: boolean
9
+ }
10
+
11
+ const { block, isFirst = false } = Astro.props
12
+ ---
13
+
14
+ <div
15
+ class={`cms-tab-panel ${isFirst ? "" : "hidden"}`}
16
+ data-block-id={block.id}
17
+ >
18
+ <slot />
19
+ </div>
@@ -0,0 +1,49 @@
1
+ ---
2
+ import type { BaseBlock } from "../../core/types/blocks"
3
+
4
+ interface Props {
5
+ block: BaseBlock
6
+ locale?: string
7
+ }
8
+
9
+ const { block } = Astro.props
10
+ const { columns = [], rows = [], caption } = block.props || {}
11
+ ---
12
+
13
+ <div class="cms-table my-6 overflow-x-auto rounded-lg border border-neutral-200 dark:border-neutral-800" data-block-id={block.id}>
14
+ <table class="w-full text-left text-sm border-collapse">
15
+ {caption && (
16
+ <caption class="p-2 text-xs text-neutral-500 dark:text-neutral-400 italic text-left">
17
+ {caption}
18
+ </caption>
19
+ )}
20
+ {columns.length > 0 && (
21
+ <thead class="bg-neutral-50 dark:bg-neutral-900 border-b border-neutral-200 dark:border-neutral-800 font-medium text-neutral-700 dark:text-neutral-200">
22
+ <tr>
23
+ {columns.map((col: any) => (
24
+ <th
25
+ class="px-4 py-3 text-xs font-semibold uppercase tracking-wider"
26
+ style={col.align ? `text-align: ${col.align};` : undefined}
27
+ >
28
+ {col.header || col.key}
29
+ </th>
30
+ ))}
31
+ </tr>
32
+ </thead>
33
+ )}
34
+ <tbody class="divide-y divide-neutral-200 dark:divide-neutral-800 bg-white dark:bg-neutral-950">
35
+ {rows.map((row: any) => (
36
+ <tr class="hover:bg-neutral-50/50 dark:hover:bg-neutral-900/50 transition-colors">
37
+ {columns.map((col: any) => (
38
+ <td
39
+ class="px-4 py-3 text-neutral-800 dark:text-neutral-200"
40
+ style={col.align ? `text-align: ${col.align};` : undefined}
41
+ >
42
+ {row[col.key] ?? ""}
43
+ </td>
44
+ ))}
45
+ </tr>
46
+ ))}
47
+ </tbody>
48
+ </table>
49
+ </div>
@@ -0,0 +1,67 @@
1
+ ---
2
+ import type { BaseBlock } from "../../core/types/blocks"
3
+
4
+ interface Props {
5
+ block: BaseBlock
6
+ locale?: string
7
+ }
8
+
9
+ const { block } = Astro.props
10
+ const children: BaseBlock[] = block.children || block.props?.children || []
11
+ const defaultValue = block.props?.defaultValue || (children[0]?.props?.value || children[0]?.props?.label || "tab-0")
12
+ const tabsId = `tabs-${block.id}`
13
+ ---
14
+
15
+ <div class="cms-tabs my-6 border border-neutral-200 dark:border-neutral-800 rounded-xl overflow-hidden" data-block-id={block.id} id={tabsId}>
16
+ <div class="flex items-center gap-1 border-b border-neutral-200 dark:border-neutral-800 bg-neutral-50 dark:bg-neutral-900 px-3 pt-2">
17
+ {children.map((child, index) => {
18
+ const val = child.props?.value || child.props?.label || `tab-${index}`
19
+ const isDefault = val === defaultValue || (!defaultValue && index === 0)
20
+ return (
21
+ <button
22
+ type="button"
23
+ class={`cms-tab-btn px-3 py-1.5 text-xs font-medium rounded-t-md transition-all border-b-2 -mb-px ${
24
+ isDefault
25
+ ? "text-primary-600 dark:text-primary-400 border-primary-500 bg-white dark:bg-neutral-950 font-semibold"
26
+ : "text-neutral-600 dark:text-neutral-400 border-transparent hover:text-neutral-900 dark:hover:text-neutral-200"
27
+ }`}
28
+ data-tab-target={`${tabsId}-panel-${index}`}
29
+ >
30
+ {child.props?.icon && <span class={`${child.props.icon} mr-1.5 inline-block size-3.5 align-text-bottom`} />}
31
+ {child.props?.label || `Tab ${index + 1}`}
32
+ </button>
33
+ )
34
+ })}
35
+ </div>
36
+ <div class="cms-tabs-content p-4 bg-white dark:bg-neutral-950">
37
+ <slot />
38
+ </div>
39
+ </div>
40
+
41
+ <script is:inline define:vars={{ tabsId }}>
42
+ (() => {
43
+ const root = document.getElementById(tabsId);
44
+ if (!root) return;
45
+ const btns = root.querySelectorAll('.cms-tab-btn');
46
+ const panels = root.querySelectorAll('.cms-tab-panel');
47
+
48
+ btns.forEach((btn, idx) => {
49
+ btn.addEventListener('click', () => {
50
+ btns.forEach((b) => {
51
+ b.classList.remove('text-primary-600', 'dark:text-primary-400', 'border-primary-500', 'bg-white', 'dark:bg-neutral-950', 'font-semibold');
52
+ b.classList.add('text-neutral-600', 'dark:text-neutral-400', 'border-transparent');
53
+ });
54
+ btn.classList.add('text-primary-600', 'dark:text-primary-400', 'border-primary-500', 'bg-white', 'dark:bg-neutral-950', 'font-semibold');
55
+ btn.classList.remove('text-neutral-600', 'dark:text-neutral-400', 'border-transparent');
56
+
57
+ panels.forEach((p, pIdx) => {
58
+ if (pIdx === idx) {
59
+ p.classList.remove('hidden');
60
+ } else {
61
+ p.classList.add('hidden');
62
+ }
63
+ });
64
+ });
65
+ });
66
+ })();
67
+ </script>
@@ -0,0 +1,55 @@
1
+ ---
2
+ import type { BaseBlock } from "../../core/types/blocks"
3
+ import type { InlineNode } from "../../core/types/inline"
4
+
5
+ interface Props {
6
+ block: BaseBlock
7
+ locale?: string
8
+ }
9
+
10
+ const { block } = Astro.props
11
+ const items = block.props?.items || block.children || []
12
+
13
+ function escapeHtml(str: string): string {
14
+ return (str || "")
15
+ .replace(/&/g, "&amp;")
16
+ .replace(/</g, "&lt;")
17
+ .replace(/>/g, "&gt;")
18
+ .replace(/"/g, "&quot;")
19
+ }
20
+
21
+ function renderNodesToHtml(nodes: InlineNode[]): string {
22
+ if (!Array.isArray(nodes)) return ""
23
+ return nodes
24
+ .map((node) => {
25
+ if (!node) return ""
26
+ if (node.type === "text") {
27
+ let t = escapeHtml(node.text || "")
28
+ if (node.marks && Array.isArray(node.marks)) {
29
+ if (node.marks.includes("bold")) t = `<strong>${t}</strong>`
30
+ if (node.marks.includes("italic")) t = `<em>${t}</em>`
31
+ if (node.marks.includes("underline")) t = `<u>${t}</u>`
32
+ if (node.marks.includes("strikethrough")) t = `<s>${t}</s>`
33
+ if (node.marks.includes("code")) {
34
+ t = `<code class="bg-neutral-800 text-neutral-200 px-1.5 py-0.5 rounded text-sm font-mono">${t}</code>`
35
+ }
36
+ }
37
+ return t
38
+ }
39
+ if (node.type === "link") {
40
+ const inner = node.children ? renderNodesToHtml(node.children) : escapeHtml((node as any).text || "")
41
+ const target = node.openInNewTab ? ' target="_blank" rel="noopener noreferrer"' : ""
42
+ const href = escapeHtml(node.url || "#")
43
+ return `<a href="${href}"${target} class="text-blue-500 hover:text-blue-400 underline underline-offset-2">${inner}</a>`
44
+ }
45
+ return ""
46
+ })
47
+ .join("")
48
+ }
49
+ ---
50
+
51
+ <ul class="cms-unordered-list list-disc list-inside space-y-2 my-4 text-base text-neutral-800 dark:text-neutral-200" data-block-id={block.id}>
52
+ {items.map((item: BaseBlock) => (
53
+ <li set:html={renderNodesToHtml(item.props?.content || [])} />
54
+ ))}
55
+ </ul>
@@ -0,0 +1,148 @@
1
+ import type { BaseBlock } from "../core/types/blocks"
2
+ import type { UserSessionContext } from "./permissions"
3
+
4
+ /**
5
+ * Checks if a user has edit rights over a specific block
6
+ */
7
+ export function canUserEditBlock(block: BaseBlock, session?: UserSessionContext): boolean {
8
+ if (block.allowedRoles && block.allowedRoles.length > 0) {
9
+ if (!session || !session.roles.some((r) => block.allowedRoles!.includes(r))) {
10
+ return false
11
+ }
12
+ }
13
+ if (block.requiredPermission) {
14
+ if (!session || !session.permissions.includes(block.requiredPermission)) {
15
+ return false
16
+ }
17
+ }
18
+ return true
19
+ }
20
+
21
+ /**
22
+ * Recursively checks if a block or ANY of its descendants are locked or un-editable
23
+ */
24
+ export function hasLockedDescendants(block: BaseBlock, session?: UserSessionContext): boolean {
25
+ if (!canUserEditBlock(block, session)) return true
26
+ if (block.isTemplated) return true
27
+
28
+ if (block.children?.length) {
29
+ return block.children.some((child) => hasLockedDescendants(child, session))
30
+ }
31
+
32
+ return false
33
+ }
34
+
35
+ /**
36
+ * Server-Side Guard: Validates that an incoming edited block tree obeys templated & authorization
37
+ * invariants
38
+ */
39
+ export function validateServerBlockPayload(
40
+ existingTree: BaseBlock[],
41
+ incomingTree: BaseBlock[],
42
+ session?: UserSessionContext
43
+ ): { valid: boolean; reason?: string } {
44
+ const existingTemplated = extractTemplatedBlocks(existingTree)
45
+ const incomingTemplated = extractTemplatedBlocks(incomingTree)
46
+
47
+ if (existingTemplated.length !== incomingTemplated.length) {
48
+ return { valid: false, reason: "Forbidden: One or more templated blocks were removed." }
49
+ }
50
+
51
+ for (let i = 0; i < existingTemplated.length; i++) {
52
+ const existing = existingTemplated[i]
53
+ const incoming = incomingTemplated[i]
54
+ if (!existing || !incoming || existing.id !== incoming.id || existing.type !== incoming.type) {
55
+ return { valid: false, reason: "Forbidden: Reordering of templated blocks is forbidden." }
56
+ }
57
+ }
58
+
59
+ for (const existingBlock of existingTemplated) {
60
+ const incomingBlock = incomingTemplated.find(
61
+ (b) => b.type === existingBlock.type && b.id === existingBlock.id
62
+ )
63
+ if (!incomingBlock) {
64
+ return {
65
+ valid: false,
66
+ reason: `Forbidden: Templated block ${existingBlock.type} (ID: ${existingBlock.id}) missing in payload.`
67
+ }
68
+ }
69
+
70
+ if (existingBlock.templatedProps === true) {
71
+ if (JSON.stringify(existingBlock.props) !== JSON.stringify(incomingBlock.props)) {
72
+ return {
73
+ valid: false,
74
+ reason: `Forbidden: Cannot modify locked properties on ${existingBlock.type}.`
75
+ }
76
+ }
77
+ } else if (Array.isArray(existingBlock.templatedProps)) {
78
+ for (const key of existingBlock.templatedProps) {
79
+ if (JSON.stringify(existingBlock.props[key]) !== JSON.stringify(incomingBlock.props[key])) {
80
+ return {
81
+ valid: false,
82
+ reason: `Forbidden: Cannot modify locked property '${key}' on ${existingBlock.type}.`
83
+ }
84
+ }
85
+ }
86
+ }
87
+ }
88
+
89
+ if (session) {
90
+ const existingProtected = extractProtectedBlocks(existingTree)
91
+ const incomingMap = new Map(flattenBlocks(incomingTree).map((b) => [b.id, b]))
92
+
93
+ for (const existingBlock of existingProtected) {
94
+ const incomingBlock = incomingMap.get(existingBlock.id)
95
+ if (!canUserEditBlock(existingBlock, session)) {
96
+ if (!incomingBlock) {
97
+ return {
98
+ valid: false,
99
+ reason: `Forbidden: Cannot delete protected block ${existingBlock.type} (ID: ${existingBlock.id}).`
100
+ }
101
+ }
102
+ if (JSON.stringify(existingBlock.props) !== JSON.stringify(incomingBlock.props)) {
103
+ return {
104
+ valid: false,
105
+ reason: `Forbidden: Cannot modify protected block ${existingBlock.type} (ID: ${existingBlock.id}).`
106
+ }
107
+ }
108
+ }
109
+ }
110
+ }
111
+
112
+ return { valid: true }
113
+ }
114
+
115
+ function getChildren(b: BaseBlock): BaseBlock[] {
116
+ if (!b) return []
117
+ if (Array.isArray(b.props?.children)) return b.props.children
118
+ if (Array.isArray(b.children)) return b.children
119
+ return []
120
+ }
121
+
122
+ function flattenBlocks(blocks: BaseBlock[]): BaseBlock[] {
123
+ let result: BaseBlock[] = []
124
+ for (const b of blocks) {
125
+ if (!b) continue
126
+ result.push(b)
127
+ const children = getChildren(b)
128
+ if (children.length) result = result.concat(flattenBlocks(children))
129
+ }
130
+ return result
131
+ }
132
+
133
+ function extractTemplatedBlocks(blocks: BaseBlock[]): BaseBlock[] {
134
+ let result: BaseBlock[] = []
135
+ for (const b of blocks) {
136
+ if (!b) continue
137
+ if (b.isTemplated) result.push(b)
138
+ const children = getChildren(b)
139
+ if (children.length) result = result.concat(extractTemplatedBlocks(children))
140
+ }
141
+ return result
142
+ }
143
+
144
+ function extractProtectedBlocks(blocks: BaseBlock[]): BaseBlock[] {
145
+ return flattenBlocks(blocks).filter(
146
+ (b) => (b.allowedRoles && b.allowedRoles.length > 0) || !!b.requiredPermission
147
+ )
148
+ }
@@ -0,0 +1,44 @@
1
+ import type { BaseBlock } from "../core/types/blocks"
2
+ import type { UserSessionContext } from "./permissions"
3
+
4
+ /**
5
+ * Pure immutable function for Astro SSR Reader. Returns new array; never mutates input AST
6
+ */
7
+ export function filterBlocksForReader(
8
+ blocks: BaseBlock[],
9
+ session?: UserSessionContext
10
+ ): BaseBlock[] {
11
+ const filtered: BaseBlock[] = []
12
+
13
+ for (const block of blocks) {
14
+ if (block.allowedRoles && block.allowedRoles.length > 0) {
15
+ if (!session || !session.roles.some((r) => block.allowedRoles!.includes(r))) {
16
+ continue
17
+ }
18
+ }
19
+ if (block.requiredPermission) {
20
+ if (!session || !session.permissions.includes(block.requiredPermission)) {
21
+ continue
22
+ }
23
+ }
24
+
25
+ const filteredChildren = block.children?.length
26
+ ? filterBlocksForReader(block.children, session)
27
+ : block.children
28
+
29
+ const newBlock: BaseBlock = {
30
+ id: block.id,
31
+ type: block.type,
32
+ props: block.props
33
+ }
34
+ if (filteredChildren !== undefined) newBlock.children = filteredChildren
35
+ if (block.isTemplated !== undefined) newBlock.isTemplated = block.isTemplated
36
+ if (block.templatedProps !== undefined) newBlock.templatedProps = block.templatedProps
37
+ if (block.allowedRoles !== undefined) newBlock.allowedRoles = block.allowedRoles
38
+ if (block.requiredPermission !== undefined)
39
+ newBlock.requiredPermission = block.requiredPermission
40
+ filtered.push(newBlock)
41
+ }
42
+
43
+ return filtered
44
+ }
@@ -0,0 +1,81 @@
1
+ import type { PageTemplate } from "../core/types/templates"
2
+ import type { UserSessionContext } from "./permissions"
3
+
4
+ export function canUserCreateFromTemplate(
5
+ template: PageTemplate,
6
+ session?: UserSessionContext
7
+ ): boolean {
8
+ const allowedCreate = template.rolePermissions?.whoCanCreate?.length
9
+ ? template.rolePermissions.whoCanCreate
10
+ : template.allowedRoles
11
+
12
+ if (allowedCreate && allowedCreate.length > 0) {
13
+ if (!session || !session.roles.some((r) => allowedCreate.includes(r))) {
14
+ return false
15
+ }
16
+ }
17
+
18
+ if (template.requiredPermission) {
19
+ if (!session || !session.permissions.includes(template.requiredPermission)) {
20
+ return false
21
+ }
22
+ }
23
+
24
+ return true
25
+ }
26
+
27
+ export function canUserEditFromTemplate(
28
+ template: PageTemplate,
29
+ session?: UserSessionContext
30
+ ): boolean {
31
+ const allowedEdit = template.rolePermissions?.whoCanEdit
32
+ if (allowedEdit && allowedEdit.length > 0) {
33
+ if (!session || !session.roles.some((r) => allowedEdit.includes(r))) {
34
+ return false
35
+ }
36
+ }
37
+ return true
38
+ }
39
+
40
+ export function canUserReviewFromTemplate(
41
+ template: PageTemplate,
42
+ session?: UserSessionContext
43
+ ): boolean {
44
+ const allowedReview = template.rolePermissions?.whoCanReview?.length
45
+ ? template.rolePermissions.whoCanReview
46
+ : template.approvalRules?.requiredRolesForApproval
47
+
48
+ if (allowedReview && allowedReview.length > 0) {
49
+ if (!session || !session.roles.some((r) => allowedReview.includes(r))) {
50
+ return false
51
+ }
52
+ }
53
+ return true
54
+ }
55
+
56
+ export function canUserViewFromTemplate(
57
+ template: PageTemplate,
58
+ session?: UserSessionContext
59
+ ): boolean {
60
+ const allowedView = template.rolePermissions?.whoCanView
61
+ if (allowedView && allowedView.length > 0) {
62
+ if (!session || !session.roles.some((r) => allowedView.includes(r))) {
63
+ return false
64
+ }
65
+ }
66
+ return true
67
+ }
68
+
69
+ export function canUserInstantiateTemplate(
70
+ template: PageTemplate,
71
+ session?: UserSessionContext
72
+ ): boolean {
73
+ return canUserCreateFromTemplate(template, session)
74
+ }
75
+
76
+ export function filterTemplatesForUser(
77
+ templates: PageTemplate[],
78
+ session?: UserSessionContext
79
+ ): PageTemplate[] {
80
+ return templates.filter((t) => canUserInstantiateTemplate(t, session))
81
+ }
@@ -0,0 +1,40 @@
1
+ export const cmsStatements = {
2
+ page: ["create", "read", "update", "delete", "publish", "approve"],
3
+ template: ["create", "read", "update", "delete", "instantiate"],
4
+ draft: ["checkout", "edit", "release", "force_unlock"],
5
+ version: ["create", "review", "approve", "publish"]
6
+ } as const
7
+
8
+ export const cmsAc = {
9
+ page: {
10
+ read: ["admin", "editor"],
11
+ update: ["admin", "editor"],
12
+ delete: ["admin"],
13
+ publish: ["admin"],
14
+ approve: ["admin"]
15
+ },
16
+ template: {
17
+ read: ["admin", "editor"],
18
+ update: ["admin"],
19
+ delete: ["admin"],
20
+ instantiate: ["admin", "editor"]
21
+ },
22
+ draft: {
23
+ checkout: ["admin", "editor"],
24
+ edit: ["admin", "editor"],
25
+ release: ["admin", "editor"],
26
+ force_unlock: ["admin"]
27
+ },
28
+ version: {
29
+ create: ["admin", "editor"],
30
+ review: ["admin", "editor"],
31
+ approve: ["admin"],
32
+ publish: ["admin"]
33
+ }
34
+ } as const
35
+
36
+ export interface UserSessionContext {
37
+ userId: string
38
+ roles: string[]
39
+ permissions: string[]
40
+ }