@rimelight/cms 0.0.2 → 0.0.3
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/LICENSE +21 -0
- package/package.json +16 -14
- package/src/admin/api/media-file.ts +39 -0
- package/src/admin/api/media.ts +199 -0
- package/src/admin/layouts/CMSDashboardLayout.astro +178 -0
- package/src/admin/layouts/DefaultParentLayout.astro +50 -0
- package/src/admin/pages/assets.astro +424 -0
- package/src/admin/pages/byline-schema.astro +62 -0
- package/src/admin/pages/bylines.astro +16 -0
- package/src/admin/pages/categories.astro +21 -0
- package/src/admin/pages/content-types.astro +55 -0
- package/src/admin/pages/dashboard.astro +61 -0
- package/src/admin/pages/pages-edit.astro +595 -0
- package/src/admin/pages/pages-index.astro +146 -0
- package/src/admin/pages/pages-new.astro +130 -0
- package/src/admin/pages/pages-preview.astro +111 -0
- package/src/admin/pages/pages-review.astro +236 -0
- package/src/admin/pages/settings.astro +203 -0
- package/src/admin/pages/tags.astro +21 -0
- package/src/admin/pages/templates-edit.astro +234 -0
- package/src/admin/pages/templates-index.astro +95 -0
- package/src/admin/pages/templates-new.astro +193 -0
- package/src/admin/pages/users.astro +74 -0
- package/src/admin/pages/versions.astro +74 -0
- package/src/astro/BlockRenderer.astro +44 -102
- package/src/astro/PageRenderer.astro +9 -2
- package/src/astro/blocks/CalloutBlock.astro +7 -7
- package/src/astro/blocks/CardBlock.astro +5 -5
- package/src/astro/blocks/CodeBlock.astro +2 -2
- package/src/astro/blocks/FileTreeBlock.astro +7 -7
- package/src/astro/blocks/ImageBlock.astro +2 -2
- package/src/astro/blocks/OrderedListBlock.astro +2 -2
- package/src/astro/blocks/ParagraphBlock.astro +3 -3
- package/src/astro/blocks/SectionBlock.astro +6 -6
- package/src/astro/blocks/StepItemBlock.astro +3 -3
- package/src/astro/blocks/StepsBlock.astro +1 -1
- package/src/astro/blocks/TableBlock.astro +6 -6
- package/src/astro/blocks/TabsBlock.astro +9 -9
- package/src/astro/blocks/UnorderedListBlock.astro +2 -2
- package/src/astro/blocks/index.ts +35 -0
- package/src/client/components/RimelightBlock.tsx +19 -19
- package/src/client/components/RimelightBlockPicker.tsx +9 -13
- package/src/client/components/RimelightBylinesManager.tsx +438 -0
- package/src/client/components/RimelightEditor.tsx +3 -3
- package/src/client/components/RimelightPreview.tsx +37 -45
- package/src/client/components/RimelightPropertiesPanel.tsx +368 -26
- package/src/client/components/RimelightTaxonomyManager.tsx +369 -0
- package/src/client/components/RimelightTaxonomyMasterDetail.tsx +412 -0
- package/src/client/components/RimelightTemplateEditor.tsx +6 -8
- package/src/client/components/editors/CalloutEditor.tsx +2 -4
- package/src/client/components/editors/CardsEditor.tsx +2 -4
- package/src/client/components/editors/CodeEditor.tsx +1 -3
- package/src/client/components/editors/FileTreeEditor.tsx +2 -4
- package/src/client/components/editors/ImageEditor.tsx +3 -3
- package/src/client/components/editors/ParagraphEditor.tsx +2 -2
- package/src/client/components/editors/SceneEditor.tsx +6 -12
- package/src/client/components/editors/ScriptEditor.tsx +8 -16
- package/src/client/components/editors/SectionEditor.tsx +4 -4
- package/src/client/components/editors/StepItemEditor.tsx +2 -2
- package/src/client/components/editors/StepsEditor.tsx +2 -4
- package/src/client/components/editors/TabItemEditor.tsx +3 -3
- package/src/client/components/editors/TableEditor.tsx +5 -5
- package/src/client/components/editors/TabsEditor.tsx +2 -4
- package/src/client/components/index.ts +3 -0
- package/src/client/loader.ts +3 -0
- package/src/core/diff.ts +145 -0
- package/src/core/excerpt.ts +159 -0
- package/src/core/preview.ts +87 -0
- package/src/core/registry.ts +0 -86
- package/src/core/types/blocks.ts +6 -1
- package/src/core/types/bylines.ts +14 -0
- package/src/core/types/media.ts +13 -0
- package/src/core/types/taxonomies.ts +20 -0
- package/src/cron/scheduled.ts +57 -0
- package/src/docs/agent.ts +116 -0
- package/src/docs/config.ts +25 -0
- package/src/docs/index.ts +5 -0
- package/src/docs/routing.ts +104 -0
- package/src/docs/sidebar.ts +251 -0
- package/src/docs/toc.ts +47 -0
- package/src/env.d.ts +13 -0
- package/src/index.ts +40 -8
- package/src/integration.ts +196 -0
- package/src/loader/live.ts +254 -0
- package/src/markdown/index.ts +0 -1
- package/src/mcp/index.ts +228 -0
- package/src/schema/bylines.ts +23 -0
- package/src/schema/index.ts +3 -4
- package/src/schema/page_versions.ts +2 -0
- package/src/schema/pages.ts +2 -0
- package/src/schema/site_settings.ts +41 -0
- package/src/schema/taxonomies.ts +49 -0
- package/src/services/bylines.ts +74 -0
- package/src/services/search-indexer.ts +61 -0
- package/src/services/site-settings.ts +108 -0
- package/src/services/taxonomies.ts +276 -0
- package/src/storage/index.ts +253 -0
- package/src/astro/blocks/ApiDocumentationBlock.astro +0 -350
- package/src/astro/blocks/DialogueBlock.astro +0 -23
- package/src/astro/blocks/LivePreviewBlock.astro +0 -27
- package/src/astro/blocks/SceneBlock.astro +0 -65
- package/src/astro/blocks/ScriptBlock.astro +0 -59
- package/src/core/template-sync.ts +0 -75
- package/src/markdown/parser.ts +0 -347
- package/src/migration-tools.ts +0 -334
- package/src/migration.ts +0 -155
- package/src/schema/component_dependencies.ts +0 -21
- package/src/schema/component_metadata.ts +0 -31
- package/src/schema/component_metadata_cache.ts +0 -15
- package/src/schema/component_versions.ts +0 -19
- package/src/search/sync.ts +0 -32
- package/src/services/component-metadata.ts +0 -134
|
@@ -93,9 +93,7 @@ export const ScriptEditor: Component<ScriptEditorProps> = (componentProps) => {
|
|
|
93
93
|
return (
|
|
94
94
|
<div class="rimelight-script-editor flex flex-col gap-4 p-3 border-l-2 border-primary/50 w-full">
|
|
95
95
|
<div class="flex flex-col gap-1">
|
|
96
|
-
<span class="text-xs font-bold text-muted
|
|
97
|
-
Script Title
|
|
98
|
-
</span>
|
|
96
|
+
<span class="text-xs font-bold text-muted uppercase tracking-widest">Script Title</span>
|
|
99
97
|
<RLSInput
|
|
100
98
|
data-field="title"
|
|
101
99
|
value={componentProps.props.title || ""}
|
|
@@ -106,9 +104,7 @@ export const ScriptEditor: Component<ScriptEditorProps> = (componentProps) => {
|
|
|
106
104
|
</div>
|
|
107
105
|
|
|
108
106
|
<div class="flex flex-col gap-1">
|
|
109
|
-
<span class="text-xs font-bold text-muted
|
|
110
|
-
Synopsis
|
|
111
|
-
</span>
|
|
107
|
+
<span class="text-xs font-bold text-muted uppercase tracking-widest">Synopsis</span>
|
|
112
108
|
<RLSTextarea
|
|
113
109
|
data-field="synopsis"
|
|
114
110
|
value={componentProps.props.synopsis || ""}
|
|
@@ -119,12 +115,10 @@ export const ScriptEditor: Component<ScriptEditorProps> = (componentProps) => {
|
|
|
119
115
|
</div>
|
|
120
116
|
|
|
121
117
|
<div class="flex flex-col gap-1">
|
|
122
|
-
<span class="text-xs font-bold text-muted
|
|
123
|
-
Characters
|
|
124
|
-
</span>
|
|
118
|
+
<span class="text-xs font-bold text-muted uppercase tracking-widest">Characters</span>
|
|
125
119
|
<div class="chars-container flex flex-wrap gap-1.5 mb-1.5 min-h-[24px]">
|
|
126
120
|
{characters().length === 0 ? (
|
|
127
|
-
<div class="text-xs text-muted
|
|
121
|
+
<div class="text-xs text-muted italic py-0.5">No characters added</div>
|
|
128
122
|
) : (
|
|
129
123
|
<For each={characters()}>
|
|
130
124
|
{(char) => (
|
|
@@ -169,7 +163,7 @@ export const ScriptEditor: Component<ScriptEditorProps> = (componentProps) => {
|
|
|
169
163
|
</div>
|
|
170
164
|
|
|
171
165
|
<div class="flex flex-col gap-1">
|
|
172
|
-
<span class="text-xs font-bold text-muted
|
|
166
|
+
<span class="text-xs font-bold text-muted uppercase tracking-widest">Plots</span>
|
|
173
167
|
<div class="grid grid-cols-3 gap-2">
|
|
174
168
|
<RLSInput
|
|
175
169
|
data-field="aPlot"
|
|
@@ -196,9 +190,7 @@ export const ScriptEditor: Component<ScriptEditorProps> = (componentProps) => {
|
|
|
196
190
|
</div>
|
|
197
191
|
|
|
198
192
|
<div class="flex flex-col gap-1">
|
|
199
|
-
<span class="text-xs font-bold text-muted
|
|
200
|
-
Stinger
|
|
201
|
-
</span>
|
|
193
|
+
<span class="text-xs font-bold text-muted uppercase tracking-widest">Stinger</span>
|
|
202
194
|
<RLSInput
|
|
203
195
|
data-field="stinger"
|
|
204
196
|
value={componentProps.props.stinger || ""}
|
|
@@ -209,7 +201,7 @@ export const ScriptEditor: Component<ScriptEditorProps> = (componentProps) => {
|
|
|
209
201
|
</div>
|
|
210
202
|
|
|
211
203
|
<div class="mt-2">
|
|
212
|
-
<span class="text-xs font-bold text-muted
|
|
204
|
+
<span class="text-xs font-bold text-muted uppercase tracking-widest block mb-1">
|
|
213
205
|
Scenes & Content
|
|
214
206
|
</span>
|
|
215
207
|
<div
|
|
@@ -218,7 +210,7 @@ export const ScriptEditor: Component<ScriptEditorProps> = (componentProps) => {
|
|
|
218
210
|
>
|
|
219
211
|
{childrenBlocks().length === 0 ? (
|
|
220
212
|
<div class="cms-empty-placeholder relative flex flex-col items-center justify-center gap-2 p-3 rounded-lg border border-dashed border-default text-center my-1">
|
|
221
|
-
<div class="text-xs font-semibold text-muted
|
|
213
|
+
<div class="text-xs font-semibold text-muted">Empty script content</div>
|
|
222
214
|
<RLSButton
|
|
223
215
|
variant="outline"
|
|
224
216
|
color="neutral"
|
|
@@ -78,7 +78,7 @@ export const SectionEditor: Component<SectionEditorProps> = (componentProps) =>
|
|
|
78
78
|
return (
|
|
79
79
|
<div class="w-full flex flex-col gap-2 py-0.5">
|
|
80
80
|
<div class="flex items-center gap-2.5">
|
|
81
|
-
<span class="text-xs font-bold text-muted
|
|
81
|
+
<span class="text-xs font-bold text-muted select-none shrink-0 tracking-wider">
|
|
82
82
|
H{headingLevel()}
|
|
83
83
|
</span>
|
|
84
84
|
<input
|
|
@@ -87,7 +87,7 @@ export const SectionEditor: Component<SectionEditorProps> = (componentProps) =>
|
|
|
87
87
|
value={componentProps.props.title || ""}
|
|
88
88
|
onInput={saveProps}
|
|
89
89
|
placeholder="Section Title"
|
|
90
|
-
class="flex-1 font-bold text-lg bg-transparent border-0 focus:outline-none text-
|
|
90
|
+
class="flex-1 font-bold text-lg bg-transparent border-0 focus:outline-none text-highlighted placeholder:text-muted/60 py-0.5"
|
|
91
91
|
/>
|
|
92
92
|
</div>
|
|
93
93
|
|
|
@@ -97,7 +97,7 @@ export const SectionEditor: Component<SectionEditorProps> = (componentProps) =>
|
|
|
97
97
|
value={componentProps.props.description || ""}
|
|
98
98
|
onInput={saveProps}
|
|
99
99
|
placeholder="Section description (optional)..."
|
|
100
|
-
class="text-sm text-muted
|
|
100
|
+
class="text-sm text-muted bg-transparent border-0 focus:outline-none placeholder:text-muted/50 py-0.5"
|
|
101
101
|
/>
|
|
102
102
|
|
|
103
103
|
<div
|
|
@@ -106,7 +106,7 @@ export const SectionEditor: Component<SectionEditorProps> = (componentProps) =>
|
|
|
106
106
|
>
|
|
107
107
|
{childrenBlocks().length === 0 ? (
|
|
108
108
|
<div class="flex flex-col items-center justify-center p-3 border border-dashed border-default rounded-lg text-center">
|
|
109
|
-
<span class="text-xs text-muted
|
|
109
|
+
<span class="text-xs text-muted">Empty section</span>
|
|
110
110
|
<RLSButton
|
|
111
111
|
variant="link"
|
|
112
112
|
color="primary"
|
|
@@ -91,11 +91,11 @@ export const StepItemEditor: Component<StepItemEditorProps> = (componentProps) =
|
|
|
91
91
|
|
|
92
92
|
<div
|
|
93
93
|
ref={(el) => (childrenContainer = el)}
|
|
94
|
-
class="p-2 rounded-lg border border-dashed border-default flex flex-col gap-2 min-h-[36px] bg-
|
|
94
|
+
class="p-2 rounded-lg border border-dashed border-default flex flex-col gap-2 min-h-[36px] bg-neutral-50"
|
|
95
95
|
>
|
|
96
96
|
{childrenBlocks().length === 0 ? (
|
|
97
97
|
<div class="text-center py-2">
|
|
98
|
-
<span class="text-xs text-muted
|
|
98
|
+
<span class="text-xs text-muted block">Empty step content</span>
|
|
99
99
|
<RLSButton
|
|
100
100
|
variant="link"
|
|
101
101
|
color="primary"
|
|
@@ -64,9 +64,7 @@ export const StepsEditor: Component<StepsEditorProps> = (componentProps) => {
|
|
|
64
64
|
return (
|
|
65
65
|
<div class="rimelight-steps-editor flex flex-col gap-3 p-3 rounded-xl border border-default/70 w-full">
|
|
66
66
|
<div class="flex items-center justify-between">
|
|
67
|
-
<span class="text-xs font-bold uppercase tracking-wider text-muted
|
|
68
|
-
Steps Walkthrough
|
|
69
|
-
</span>
|
|
67
|
+
<span class="text-xs font-bold uppercase tracking-wider text-muted">Steps Walkthrough</span>
|
|
70
68
|
<RLSButton
|
|
71
69
|
variant="outline"
|
|
72
70
|
color="neutral"
|
|
@@ -83,7 +81,7 @@ export const StepsEditor: Component<StepsEditorProps> = (componentProps) => {
|
|
|
83
81
|
>
|
|
84
82
|
{childrenBlocks().length === 0 ? (
|
|
85
83
|
<div class="text-center py-3">
|
|
86
|
-
<span class="text-xs text-muted
|
|
84
|
+
<span class="text-xs text-muted block mb-2">No steps in walkthrough</span>
|
|
87
85
|
<RLSButton
|
|
88
86
|
variant="link"
|
|
89
87
|
color="primary"
|
|
@@ -73,7 +73,7 @@ export const TabItemEditor: Component<TabItemEditorProps> = (componentProps) =>
|
|
|
73
73
|
return (
|
|
74
74
|
<div class="rimelight-tab-item-editor flex flex-col gap-2 p-3 rounded-lg border border-default bg-muted/10 w-full">
|
|
75
75
|
<div class="flex items-center gap-2">
|
|
76
|
-
<span class="text-xs font-semibold text-muted
|
|
76
|
+
<span class="text-xs font-semibold text-muted uppercase">Tab Label:</span>
|
|
77
77
|
<div class="flex-1 max-w-xs">
|
|
78
78
|
<RLSInput
|
|
79
79
|
type="text"
|
|
@@ -87,11 +87,11 @@ export const TabItemEditor: Component<TabItemEditorProps> = (componentProps) =>
|
|
|
87
87
|
|
|
88
88
|
<div
|
|
89
89
|
ref={(el) => (childrenContainer = el)}
|
|
90
|
-
class="p-2 rounded-lg border border-dashed border-default flex flex-col gap-2 min-h-[36px] bg-
|
|
90
|
+
class="p-2 rounded-lg border border-dashed border-default flex flex-col gap-2 min-h-[36px] bg-neutral-50"
|
|
91
91
|
>
|
|
92
92
|
{childrenBlocks().length === 0 ? (
|
|
93
93
|
<div class="text-center py-2">
|
|
94
|
-
<span class="text-xs text-muted
|
|
94
|
+
<span class="text-xs text-muted block">Empty tab content</span>
|
|
95
95
|
<RLSButton
|
|
96
96
|
variant="link"
|
|
97
97
|
color="primary"
|
|
@@ -78,7 +78,7 @@ export const TableEditor: Component<TableEditorProps> = (componentProps) => {
|
|
|
78
78
|
return (
|
|
79
79
|
<div class="rimelight-table-editor flex flex-col gap-3 p-3 rounded-xl border border-default/70 w-full overflow-x-auto">
|
|
80
80
|
<div class="flex items-center justify-between">
|
|
81
|
-
<span class="text-xs font-bold uppercase tracking-wider text-muted
|
|
81
|
+
<span class="text-xs font-bold uppercase tracking-wider text-muted">Table</span>
|
|
82
82
|
<div class="flex items-center gap-2">
|
|
83
83
|
<RLSButton
|
|
84
84
|
variant="outline"
|
|
@@ -110,7 +110,7 @@ export const TableEditor: Component<TableEditorProps> = (componentProps) => {
|
|
|
110
110
|
<input
|
|
111
111
|
type="text"
|
|
112
112
|
aria-label={`Column ${colIdx() + 1} header`}
|
|
113
|
-
class="w-full bg-transparent border border-transparent hover:border-default focus:border-primary rounded px-2 py-1 font-semibold outline-none text-
|
|
113
|
+
class="w-full bg-transparent border border-transparent hover:border-default focus:border-primary rounded px-2 py-1 font-semibold outline-none text-highlighted"
|
|
114
114
|
value={col.header}
|
|
115
115
|
onBlur={(e) => updateHeader(colIdx(), e.currentTarget.value)}
|
|
116
116
|
/>
|
|
@@ -118,7 +118,7 @@ export const TableEditor: Component<TableEditorProps> = (componentProps) => {
|
|
|
118
118
|
<button
|
|
119
119
|
type="button"
|
|
120
120
|
aria-label="Delete column"
|
|
121
|
-
class="text-muted
|
|
121
|
+
class="text-muted hover:text-danger p-1 text-xs"
|
|
122
122
|
onClick={() => removeColumn(colIdx())}
|
|
123
123
|
title="Delete column"
|
|
124
124
|
>
|
|
@@ -142,7 +142,7 @@ export const TableEditor: Component<TableEditorProps> = (componentProps) => {
|
|
|
142
142
|
<input
|
|
143
143
|
type="text"
|
|
144
144
|
aria-label={`${col.header} row ${rowIdx() + 1}`}
|
|
145
|
-
class="w-full bg-transparent border border-transparent hover:border-default focus:border-primary rounded px-2 py-1 outline-none text-
|
|
145
|
+
class="w-full bg-transparent border border-transparent hover:border-default focus:border-primary rounded px-2 py-1 outline-none text-highlighted"
|
|
146
146
|
value={row[col.key] || ""}
|
|
147
147
|
onBlur={(e) => updateCell(rowIdx(), col.key, e.currentTarget.value)}
|
|
148
148
|
/>
|
|
@@ -154,7 +154,7 @@ export const TableEditor: Component<TableEditorProps> = (componentProps) => {
|
|
|
154
154
|
<button
|
|
155
155
|
type="button"
|
|
156
156
|
aria-label="Delete row"
|
|
157
|
-
class="text-muted
|
|
157
|
+
class="text-muted hover:text-danger p-1 text-xs"
|
|
158
158
|
onClick={() => removeRow(rowIdx())}
|
|
159
159
|
title="Delete row"
|
|
160
160
|
>
|
|
@@ -64,9 +64,7 @@ export const TabsEditor: Component<TabsEditorProps> = (componentProps) => {
|
|
|
64
64
|
return (
|
|
65
65
|
<div class="rimelight-tabs-editor flex flex-col gap-3 p-3 rounded-xl border border-default/70 w-full">
|
|
66
66
|
<div class="flex items-center justify-between">
|
|
67
|
-
<span class="text-xs font-bold uppercase tracking-wider text-muted
|
|
68
|
-
Tabs Container
|
|
69
|
-
</span>
|
|
67
|
+
<span class="text-xs font-bold uppercase tracking-wider text-muted">Tabs Container</span>
|
|
70
68
|
<RLSButton
|
|
71
69
|
variant="outline"
|
|
72
70
|
color="neutral"
|
|
@@ -83,7 +81,7 @@ export const TabsEditor: Component<TabsEditorProps> = (componentProps) => {
|
|
|
83
81
|
>
|
|
84
82
|
{childrenBlocks().length === 0 ? (
|
|
85
83
|
<div class="text-center py-3">
|
|
86
|
-
<span class="text-xs text-muted
|
|
84
|
+
<span class="text-xs text-muted block mb-2">No tabs in container</span>
|
|
87
85
|
<RLSButton
|
|
88
86
|
variant="link"
|
|
89
87
|
color="primary"
|
|
@@ -5,3 +5,6 @@ export * from "./RimelightEditorLayout"
|
|
|
5
5
|
export * from "./RimelightPreview"
|
|
6
6
|
export * from "./RimelightPropertiesPanel"
|
|
7
7
|
export * from "./RimelightTemplateEditor"
|
|
8
|
+
export * from "./RimelightTaxonomyManager"
|
|
9
|
+
export * from "./RimelightBylinesManager"
|
|
10
|
+
export * from "./RimelightTaxonomyMasterDetail"
|
package/src/client/loader.ts
CHANGED
|
@@ -8,6 +8,9 @@ export { RimelightPreview } from "./components/RimelightPreview"
|
|
|
8
8
|
export { RimelightPropertiesPanel } from "./components/RimelightPropertiesPanel"
|
|
9
9
|
export { RimelightBlockPicker } from "./components/RimelightBlockPicker"
|
|
10
10
|
export { RimelightTemplateEditor } from "./components/RimelightTemplateEditor"
|
|
11
|
+
export { RimelightTaxonomyManager } from "./components/RimelightTaxonomyManager"
|
|
12
|
+
export { RimelightBylinesManager } from "./components/RimelightBylinesManager"
|
|
13
|
+
export { RimelightTaxonomyMasterDetail } from "./components/RimelightTaxonomyMasterDetail"
|
|
11
14
|
|
|
12
15
|
export { ParagraphEditor } from "./components/editors/ParagraphEditor"
|
|
13
16
|
export { CalloutEditor } from "./components/editors/CalloutEditor"
|
package/src/core/diff.ts
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import type { BaseBlock } from "./types/blocks"
|
|
2
|
+
|
|
3
|
+
export type DiffChangeType = "added" | "removed" | "modified" | "unchanged"
|
|
4
|
+
|
|
5
|
+
export interface BlockDiff {
|
|
6
|
+
blockId: string
|
|
7
|
+
type: string
|
|
8
|
+
status: DiffChangeType
|
|
9
|
+
oldBlock?: BaseBlock
|
|
10
|
+
newBlock?: BaseBlock
|
|
11
|
+
fieldChanges?: {
|
|
12
|
+
field: string
|
|
13
|
+
oldValue: any
|
|
14
|
+
newValue: any
|
|
15
|
+
}[]
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface PageVersionDiff {
|
|
19
|
+
blocks: BlockDiff[]
|
|
20
|
+
metaChanges: {
|
|
21
|
+
field: string
|
|
22
|
+
oldValue: any
|
|
23
|
+
newValue: any
|
|
24
|
+
}[]
|
|
25
|
+
summary: {
|
|
26
|
+
addedCount: number
|
|
27
|
+
removedCount: number
|
|
28
|
+
modifiedCount: number
|
|
29
|
+
unchangedCount: number
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Calculates a detailed diff between an old list of blocks and a new list of blocks.
|
|
35
|
+
*/
|
|
36
|
+
export function diffBlocks(oldBlocks: BaseBlock[] = [], newBlocks: BaseBlock[] = []): BlockDiff[] {
|
|
37
|
+
const diffs: BlockDiff[] = []
|
|
38
|
+
const oldMap = new Map<string, { block: BaseBlock; index: number }>()
|
|
39
|
+
const newMap = new Map<string, { block: BaseBlock; index: number }>()
|
|
40
|
+
|
|
41
|
+
oldBlocks.forEach((block, index) => {
|
|
42
|
+
oldMap.set(block.id, { block, index })
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
newBlocks.forEach((block, index) => {
|
|
46
|
+
newMap.set(block.id, { block, index })
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
// Check new blocks (added or modified/unchanged)
|
|
50
|
+
for (const newBlock of newBlocks) {
|
|
51
|
+
const oldEntry = oldMap.get(newBlock.id)
|
|
52
|
+
|
|
53
|
+
if (!oldEntry) {
|
|
54
|
+
diffs.push({
|
|
55
|
+
blockId: newBlock.id,
|
|
56
|
+
type: newBlock.type,
|
|
57
|
+
status: "added",
|
|
58
|
+
newBlock
|
|
59
|
+
})
|
|
60
|
+
} else {
|
|
61
|
+
const oldBlock = oldEntry.block
|
|
62
|
+
const fieldChanges: { field: string; oldValue: any; newValue: any }[] = []
|
|
63
|
+
|
|
64
|
+
const oldProps = (oldBlock as any).props || (oldBlock as any).properties || {}
|
|
65
|
+
const newProps = (newBlock as any).props || (newBlock as any).properties || {}
|
|
66
|
+
|
|
67
|
+
// Compare properties
|
|
68
|
+
const allPropKeys = new Set([...Object.keys(oldProps), ...Object.keys(newProps)])
|
|
69
|
+
|
|
70
|
+
for (const key of allPropKeys) {
|
|
71
|
+
const oldVal = oldProps[key]
|
|
72
|
+
const newVal = newProps[key]
|
|
73
|
+
if (JSON.stringify(oldVal) !== JSON.stringify(newVal)) {
|
|
74
|
+
fieldChanges.push({ field: `props.${key}`, oldValue: oldVal, newValue: newVal })
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (fieldChanges.length > 0) {
|
|
79
|
+
diffs.push({
|
|
80
|
+
blockId: newBlock.id,
|
|
81
|
+
type: newBlock.type,
|
|
82
|
+
status: "modified",
|
|
83
|
+
oldBlock,
|
|
84
|
+
newBlock,
|
|
85
|
+
fieldChanges
|
|
86
|
+
})
|
|
87
|
+
} else {
|
|
88
|
+
diffs.push({
|
|
89
|
+
blockId: newBlock.id,
|
|
90
|
+
type: newBlock.type,
|
|
91
|
+
status: "unchanged",
|
|
92
|
+
oldBlock,
|
|
93
|
+
newBlock
|
|
94
|
+
})
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Check for removed blocks
|
|
100
|
+
for (const oldBlock of oldBlocks) {
|
|
101
|
+
if (!newMap.has(oldBlock.id)) {
|
|
102
|
+
diffs.push({
|
|
103
|
+
blockId: oldBlock.id,
|
|
104
|
+
type: oldBlock.type,
|
|
105
|
+
status: "removed",
|
|
106
|
+
oldBlock
|
|
107
|
+
})
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return diffs
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Compares two page snapshots (metadata + content blocks).
|
|
116
|
+
*/
|
|
117
|
+
export function diffPageSnapshots(oldSnapshot: any, newSnapshot: any): PageVersionDiff {
|
|
118
|
+
const oldBlocks = oldSnapshot?.content?.blocks || []
|
|
119
|
+
const newBlocks = newSnapshot?.content?.blocks || []
|
|
120
|
+
const blockDiffs = diffBlocks(oldBlocks, newBlocks)
|
|
121
|
+
|
|
122
|
+
const metaFields = ["title", "slug", "description", "tags", "type", "authorIds", "bylines"]
|
|
123
|
+
const metaChanges: { field: string; oldValue: any; newValue: any }[] = []
|
|
124
|
+
|
|
125
|
+
for (const field of metaFields) {
|
|
126
|
+
const oldVal = oldSnapshot?.[field]
|
|
127
|
+
const newVal = newSnapshot?.[field]
|
|
128
|
+
if (JSON.stringify(oldVal) !== JSON.stringify(newVal)) {
|
|
129
|
+
metaChanges.push({ field, oldValue: oldVal, newValue: newVal })
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const summary = {
|
|
134
|
+
addedCount: blockDiffs.filter((d) => d.status === "added").length,
|
|
135
|
+
removedCount: blockDiffs.filter((d) => d.status === "removed").length,
|
|
136
|
+
modifiedCount: blockDiffs.filter((d) => d.status === "modified").length,
|
|
137
|
+
unchangedCount: blockDiffs.filter((d) => d.status === "unchanged").length
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return {
|
|
141
|
+
blocks: blockDiffs,
|
|
142
|
+
metaChanges,
|
|
143
|
+
summary
|
|
144
|
+
}
|
|
145
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import type { BaseBlock } from "./types/blocks.ts"
|
|
2
|
+
import type { InlineNode } from "./types/inline.ts"
|
|
3
|
+
|
|
4
|
+
function extractTextFromInlineNodes(nodes: InlineNode[] | undefined | null): string {
|
|
5
|
+
if (!nodes || !Array.isArray(nodes)) return ""
|
|
6
|
+
return nodes
|
|
7
|
+
.map((node) => {
|
|
8
|
+
if (node.type === "text") return node.text || ""
|
|
9
|
+
if (node.type === "page_mention") return node.displayTitle || node.pageSlug || ""
|
|
10
|
+
if (node.type === "link") return extractTextFromInlineNodes(node.children)
|
|
11
|
+
return ""
|
|
12
|
+
})
|
|
13
|
+
.join("")
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function extractTextFromProperty(prop: unknown, locale: string = "en"): string {
|
|
17
|
+
if (!prop) return ""
|
|
18
|
+
if (typeof prop === "string") return prop
|
|
19
|
+
if (typeof prop === "object" && prop !== null) {
|
|
20
|
+
if (Array.isArray(prop)) {
|
|
21
|
+
return extractTextFromInlineNodes(prop as InlineNode[])
|
|
22
|
+
}
|
|
23
|
+
const record = prop as Record<string, unknown>
|
|
24
|
+
if (typeof record[locale] === "string") return record[locale] as string
|
|
25
|
+
if (typeof record.en === "string") return record.en as string
|
|
26
|
+
const firstVal = Object.values(record)[0]
|
|
27
|
+
if (typeof firstVal === "string") return firstVal
|
|
28
|
+
}
|
|
29
|
+
return ""
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Recursively extracts plain text from an array of CMS blocks.
|
|
34
|
+
*/
|
|
35
|
+
export function extractTextFromBlocks(
|
|
36
|
+
blocks: BaseBlock[] | undefined | null,
|
|
37
|
+
locale: string = "en"
|
|
38
|
+
): string {
|
|
39
|
+
if (!blocks || !Array.isArray(blocks)) return ""
|
|
40
|
+
|
|
41
|
+
const chunks: string[] = []
|
|
42
|
+
|
|
43
|
+
for (const block of blocks) {
|
|
44
|
+
if (!block) continue
|
|
45
|
+
|
|
46
|
+
const props = block.props || {}
|
|
47
|
+
|
|
48
|
+
switch (block.type) {
|
|
49
|
+
case "ParagraphBlock": {
|
|
50
|
+
const text = extractTextFromProperty(props.text, locale)
|
|
51
|
+
if (text) chunks.push(text)
|
|
52
|
+
break
|
|
53
|
+
}
|
|
54
|
+
case "SectionBlock": {
|
|
55
|
+
if (props.title) chunks.push(String(props.title))
|
|
56
|
+
if (props.description) chunks.push(String(props.description))
|
|
57
|
+
if (Array.isArray(props.children)) {
|
|
58
|
+
const childText = extractTextFromBlocks(props.children, locale)
|
|
59
|
+
if (childText) chunks.push(childText)
|
|
60
|
+
}
|
|
61
|
+
break
|
|
62
|
+
}
|
|
63
|
+
case "CardBlock": {
|
|
64
|
+
if (props.title) chunks.push(String(props.title))
|
|
65
|
+
if (props.description) chunks.push(String(props.description))
|
|
66
|
+
break
|
|
67
|
+
}
|
|
68
|
+
case "DialogueBlock": {
|
|
69
|
+
if (props.character && props.line) {
|
|
70
|
+
chunks.push(`${props.character}: ${props.line}`)
|
|
71
|
+
} else if (props.line) {
|
|
72
|
+
chunks.push(String(props.line))
|
|
73
|
+
}
|
|
74
|
+
break
|
|
75
|
+
}
|
|
76
|
+
case "StepItemBlock": {
|
|
77
|
+
if (props.title) chunks.push(String(props.title))
|
|
78
|
+
if (props.description) chunks.push(String(props.description))
|
|
79
|
+
break
|
|
80
|
+
}
|
|
81
|
+
default:
|
|
82
|
+
break
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (Array.isArray(block.children) && block.children.length > 0) {
|
|
86
|
+
const childText = extractTextFromBlocks(block.children, locale)
|
|
87
|
+
if (childText) chunks.push(childText)
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return chunks.join(" ").replace(/\s+/g, " ").trim()
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export interface ExcerptOptions {
|
|
95
|
+
maxLength?: number
|
|
96
|
+
locale?: string
|
|
97
|
+
fallback?: string
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Generates an SEO / RSS safe plain text excerpt from a CMS page or block payload.
|
|
102
|
+
*/
|
|
103
|
+
export function createPageExcerpt(
|
|
104
|
+
page:
|
|
105
|
+
| {
|
|
106
|
+
description?: unknown
|
|
107
|
+
content?: unknown
|
|
108
|
+
title?: unknown
|
|
109
|
+
}
|
|
110
|
+
| null
|
|
111
|
+
| undefined,
|
|
112
|
+
options: ExcerptOptions = {}
|
|
113
|
+
): string {
|
|
114
|
+
const { maxLength = 160, locale = "en", fallback = "" } = options
|
|
115
|
+
|
|
116
|
+
if (!page) return fallback
|
|
117
|
+
|
|
118
|
+
// 1. First priority: explicit localized description
|
|
119
|
+
const explicitDesc = extractTextFromProperty(page.description, locale)
|
|
120
|
+
if (explicitDesc) {
|
|
121
|
+
return truncateExcerpt(explicitDesc, maxLength)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// 2. Second priority: content properties description
|
|
125
|
+
let contentObj = page.content
|
|
126
|
+
if (typeof contentObj === "string") {
|
|
127
|
+
try {
|
|
128
|
+
contentObj = JSON.parse(contentObj)
|
|
129
|
+
} catch {
|
|
130
|
+
contentObj = null
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (contentObj && typeof contentObj === "object") {
|
|
135
|
+
const props = (contentObj as { properties?: Record<string, unknown> }).properties
|
|
136
|
+
if (props && props.description) {
|
|
137
|
+
const descText = extractTextFromProperty(props.description, locale)
|
|
138
|
+
if (descText) return truncateExcerpt(descText, maxLength)
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// 3. Third priority: extract from blocks
|
|
142
|
+
const blocks = (contentObj as { blocks?: BaseBlock[] }).blocks
|
|
143
|
+
if (Array.isArray(blocks) && blocks.length > 0) {
|
|
144
|
+
const extracted = extractTextFromBlocks(blocks, locale)
|
|
145
|
+
if (extracted) return truncateExcerpt(extracted, maxLength)
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return fallback
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function truncateExcerpt(text: string, maxLength: number): string {
|
|
153
|
+
const clean = text
|
|
154
|
+
.replace(/<[^>]*>/g, "")
|
|
155
|
+
.replace(/\s+/g, " ")
|
|
156
|
+
.trim()
|
|
157
|
+
if (clean.length <= maxLength) return clean
|
|
158
|
+
return `${clean.slice(0, maxLength - 1).trimEnd()}…`
|
|
159
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generates and verifies HMAC-signed stateless preview tokens for draft pages.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
async function getCryptoKey(secret: string): Promise<CryptoKey> {
|
|
6
|
+
const enc = new TextEncoder()
|
|
7
|
+
return crypto.subtle.importKey(
|
|
8
|
+
"raw",
|
|
9
|
+
enc.encode(secret),
|
|
10
|
+
{ name: "HMAC", hash: "SHA-256" },
|
|
11
|
+
false,
|
|
12
|
+
["sign", "verify"]
|
|
13
|
+
)
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function hexEncode(buffer: ArrayBuffer): string {
|
|
17
|
+
const bytes = new Uint8Array(buffer)
|
|
18
|
+
return Array.from(bytes)
|
|
19
|
+
.map((b) => b.toString(16).padStart(2, "0"))
|
|
20
|
+
.join("")
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function hexDecode(hex: string): Uint8Array {
|
|
24
|
+
const matches = hex.match(/.{1,2}/g) || []
|
|
25
|
+
return new Uint8Array(matches.map((byte) => parseInt(byte, 16)))
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Generates a signed token for a page ID valid for ttlSeconds (default 24h).
|
|
30
|
+
*/
|
|
31
|
+
export async function generatePreviewToken(
|
|
32
|
+
pageId: string,
|
|
33
|
+
secret: string,
|
|
34
|
+
ttlSeconds = 86400
|
|
35
|
+
): Promise<{ token: string; expiresAt: number }> {
|
|
36
|
+
const expiresAt = Math.floor(Date.now() / 1000) + ttlSeconds
|
|
37
|
+
const payload = `${pageId}:${expiresAt}`
|
|
38
|
+
|
|
39
|
+
const key = await getCryptoKey(secret)
|
|
40
|
+
const enc = new TextEncoder()
|
|
41
|
+
const signatureBuffer = await crypto.subtle.sign("HMAC", key, enc.encode(payload))
|
|
42
|
+
const signature = hexEncode(signatureBuffer)
|
|
43
|
+
|
|
44
|
+
// Token format: base64(payload).signature
|
|
45
|
+
const token = `${btoa(payload)}.${signature}`
|
|
46
|
+
return { token, expiresAt }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Verifies a signed preview token against a pageId and secret.
|
|
51
|
+
*/
|
|
52
|
+
export async function verifyPreviewToken(
|
|
53
|
+
pageId: string,
|
|
54
|
+
token: string,
|
|
55
|
+
secret: string
|
|
56
|
+
): Promise<boolean> {
|
|
57
|
+
try {
|
|
58
|
+
const parts = token.split(".")
|
|
59
|
+
if (parts.length !== 2) return false
|
|
60
|
+
|
|
61
|
+
const [b64Payload, signatureHex] = parts
|
|
62
|
+
if (!b64Payload || !signatureHex) return false
|
|
63
|
+
|
|
64
|
+
const payload = atob(b64Payload)
|
|
65
|
+
const [tokenPageId, tokenExpStr] = payload.split(":")
|
|
66
|
+
if (!tokenPageId || !tokenExpStr) return false
|
|
67
|
+
|
|
68
|
+
if (tokenPageId !== pageId) return false
|
|
69
|
+
|
|
70
|
+
const expiresAt = parseInt(tokenExpStr, 10)
|
|
71
|
+
const now = Math.floor(Date.now() / 1000)
|
|
72
|
+
if (isNaN(expiresAt) || now > expiresAt) return false
|
|
73
|
+
|
|
74
|
+
const key = await getCryptoKey(secret)
|
|
75
|
+
const enc = new TextEncoder()
|
|
76
|
+
const signatureBytes = hexDecode(signatureHex)
|
|
77
|
+
|
|
78
|
+
return await crypto.subtle.verify(
|
|
79
|
+
"HMAC",
|
|
80
|
+
key,
|
|
81
|
+
signatureBytes as unknown as BufferSource,
|
|
82
|
+
enc.encode(payload)
|
|
83
|
+
)
|
|
84
|
+
} catch {
|
|
85
|
+
return false
|
|
86
|
+
}
|
|
87
|
+
}
|