@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.
- package/README.md +19 -0
- package/package.json +69 -0
- package/src/astro/BlockRenderer.astro +111 -0
- package/src/astro/PageRenderer.astro +23 -0
- package/src/astro/blocks/CalloutBlock.astro +78 -0
- package/src/astro/blocks/CardBlock.astro +43 -0
- package/src/astro/blocks/CardsBlock.astro +24 -0
- package/src/astro/blocks/CodeBlock.astro +16 -0
- package/src/astro/blocks/DialogueBlock.astro +23 -0
- package/src/astro/blocks/FileTreeBlock.astro +45 -0
- package/src/astro/blocks/ImageBlock.astro +17 -0
- package/src/astro/blocks/OrderedListBlock.astro +55 -0
- package/src/astro/blocks/ParagraphBlock.astro +65 -0
- package/src/astro/blocks/SceneBlock.astro +65 -0
- package/src/astro/blocks/ScriptBlock.astro +59 -0
- package/src/astro/blocks/SectionBlock.astro +46 -0
- package/src/astro/blocks/StepItemBlock.astro +25 -0
- package/src/astro/blocks/StepsBlock.astro +14 -0
- package/src/astro/blocks/TabItemBlock.astro +19 -0
- package/src/astro/blocks/TableBlock.astro +49 -0
- package/src/astro/blocks/TabsBlock.astro +67 -0
- package/src/astro/blocks/UnorderedListBlock.astro +55 -0
- package/src/auth/editor-guards.ts +148 -0
- package/src/auth/filter-blocks.ts +44 -0
- package/src/auth/filter-templates.ts +81 -0
- package/src/auth/permissions.ts +40 -0
- package/src/cache/purge.ts +37 -0
- package/src/client/components/RimelightBlock.tsx +487 -0
- package/src/client/components/RimelightBlockPicker.tsx +305 -0
- package/src/client/components/RimelightEditor.tsx +131 -0
- package/src/client/components/RimelightEditorLayout.tsx +143 -0
- package/src/client/components/RimelightPreview.tsx +354 -0
- package/src/client/components/RimelightPropertiesPanel.tsx +408 -0
- package/src/client/components/RimelightTemplateEditor.tsx +245 -0
- package/src/client/components/editors/CalloutEditor.tsx +185 -0
- package/src/client/components/editors/CardEditor.tsx +60 -0
- package/src/client/components/editors/CardsEditor.tsx +132 -0
- package/src/client/components/editors/CodeEditor.tsx +101 -0
- package/src/client/components/editors/DialogueEditor.tsx +52 -0
- package/src/client/components/editors/FileTreeEditor.tsx +65 -0
- package/src/client/components/editors/ImageEditor.tsx +70 -0
- package/src/client/components/editors/ParagraphEditor.tsx +314 -0
- package/src/client/components/editors/SceneEditor.tsx +245 -0
- package/src/client/components/editors/ScriptEditor.tsx +245 -0
- package/src/client/components/editors/SectionEditor.tsx +139 -0
- package/src/client/components/editors/StepItemEditor.tsx +117 -0
- package/src/client/components/editors/StepsEditor.tsx +105 -0
- package/src/client/components/editors/TabItemEditor.tsx +113 -0
- package/src/client/components/editors/TableEditor.tsx +181 -0
- package/src/client/components/editors/TabsEditor.tsx +105 -0
- package/src/client/components/index.ts +7 -0
- package/src/client/loader.ts +19 -0
- package/src/client/state/autosave.ts +122 -0
- package/src/client/state/editor-store.ts +411 -0
- package/src/client/state/history.ts +251 -0
- package/src/client/state/lock-manager.ts +89 -0
- package/src/client/styles/cms-editor.css +653 -0
- package/src/client/toast.ts +12 -0
- package/src/client/utils/sortable.ts +184 -0
- package/src/client/utils/splitpane.ts +87 -0
- package/src/client/utils/tree-sanitizer.ts +33 -0
- package/src/core/page-definitions.ts +502 -0
- package/src/core/registry.ts +99 -0
- package/src/core/template-sync.ts +75 -0
- package/src/core/types/blocks.ts +367 -0
- package/src/core/types/inline.ts +23 -0
- package/src/core/types/pages.ts +36 -0
- package/src/core/types/templates.ts +32 -0
- package/src/core/types/versioning.ts +44 -0
- package/src/core/validator.ts +28 -0
- package/src/env.d.ts +4 -0
- package/src/index.ts +47 -0
- package/src/markdown/index.ts +2 -0
- package/src/markdown/parser.ts +347 -0
- package/src/markdown/serializer.ts +219 -0
- package/src/migration.ts +155 -0
- package/src/schema/index.ts +8 -0
- package/src/schema/page_draft_locks.ts +13 -0
- package/src/schema/page_drafts.ts +18 -0
- package/src/schema/page_templates.ts +27 -0
- package/src/schema/page_version_approvals.ts +12 -0
- package/src/schema/page_version_comments.ts +14 -0
- package/src/schema/page_versions.ts +34 -0
- package/src/schema/pages.ts +42 -0
- package/src/schema/search.ts +23 -0
- package/src/search/query.ts +10 -0
- 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, "&")
|
|
16
|
+
.replace(/</g, "<")
|
|
17
|
+
.replace(/>/g, ">")
|
|
18
|
+
.replace(/"/g, """)
|
|
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
|
+
}
|