@rimelight/cms 0.0.1 → 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.
Files changed (104) hide show
  1. package/LICENSE +21 -0
  2. package/package.json +16 -14
  3. package/src/admin/api/media-file.ts +39 -0
  4. package/src/admin/api/media.ts +199 -0
  5. package/src/admin/layouts/CMSDashboardLayout.astro +178 -0
  6. package/src/admin/layouts/DefaultParentLayout.astro +50 -0
  7. package/src/admin/pages/assets.astro +424 -0
  8. package/src/admin/pages/byline-schema.astro +62 -0
  9. package/src/admin/pages/bylines.astro +16 -0
  10. package/src/admin/pages/categories.astro +21 -0
  11. package/src/admin/pages/content-types.astro +55 -0
  12. package/src/admin/pages/dashboard.astro +61 -0
  13. package/src/admin/pages/pages-edit.astro +595 -0
  14. package/src/admin/pages/pages-index.astro +146 -0
  15. package/src/admin/pages/pages-new.astro +130 -0
  16. package/src/admin/pages/pages-preview.astro +111 -0
  17. package/src/admin/pages/pages-review.astro +236 -0
  18. package/src/admin/pages/settings.astro +203 -0
  19. package/src/admin/pages/tags.astro +21 -0
  20. package/src/admin/pages/templates-edit.astro +234 -0
  21. package/src/admin/pages/templates-index.astro +95 -0
  22. package/src/admin/pages/templates-new.astro +193 -0
  23. package/src/admin/pages/users.astro +74 -0
  24. package/src/admin/pages/versions.astro +74 -0
  25. package/src/astro/BlockRenderer.astro +44 -96
  26. package/src/astro/PageRenderer.astro +9 -2
  27. package/src/astro/blocks/CalloutBlock.astro +7 -7
  28. package/src/astro/blocks/CardBlock.astro +5 -5
  29. package/src/astro/blocks/CodeBlock.astro +2 -2
  30. package/src/astro/blocks/FileTreeBlock.astro +7 -7
  31. package/src/astro/blocks/ImageBlock.astro +2 -2
  32. package/src/astro/blocks/OrderedListBlock.astro +2 -2
  33. package/src/astro/blocks/ParagraphBlock.astro +3 -3
  34. package/src/astro/blocks/SectionBlock.astro +6 -6
  35. package/src/astro/blocks/StepItemBlock.astro +3 -3
  36. package/src/astro/blocks/StepsBlock.astro +1 -1
  37. package/src/astro/blocks/TableBlock.astro +6 -6
  38. package/src/astro/blocks/TabsBlock.astro +9 -9
  39. package/src/astro/blocks/UnorderedListBlock.astro +2 -2
  40. package/src/astro/blocks/index.ts +35 -0
  41. package/src/client/components/RimelightBlock.tsx +19 -19
  42. package/src/client/components/RimelightBlockPicker.tsx +9 -13
  43. package/src/client/components/RimelightBylinesManager.tsx +438 -0
  44. package/src/client/components/RimelightEditor.tsx +3 -3
  45. package/src/client/components/RimelightPreview.tsx +37 -45
  46. package/src/client/components/RimelightPropertiesPanel.tsx +368 -26
  47. package/src/client/components/RimelightTaxonomyManager.tsx +369 -0
  48. package/src/client/components/RimelightTaxonomyMasterDetail.tsx +412 -0
  49. package/src/client/components/RimelightTemplateEditor.tsx +6 -8
  50. package/src/client/components/editors/CalloutEditor.tsx +2 -4
  51. package/src/client/components/editors/CardsEditor.tsx +2 -4
  52. package/src/client/components/editors/CodeEditor.tsx +1 -3
  53. package/src/client/components/editors/FileTreeEditor.tsx +2 -4
  54. package/src/client/components/editors/ImageEditor.tsx +3 -3
  55. package/src/client/components/editors/ParagraphEditor.tsx +2 -2
  56. package/src/client/components/editors/SceneEditor.tsx +6 -12
  57. package/src/client/components/editors/ScriptEditor.tsx +8 -16
  58. package/src/client/components/editors/SectionEditor.tsx +4 -4
  59. package/src/client/components/editors/StepItemEditor.tsx +2 -2
  60. package/src/client/components/editors/StepsEditor.tsx +2 -4
  61. package/src/client/components/editors/TabItemEditor.tsx +3 -3
  62. package/src/client/components/editors/TableEditor.tsx +5 -5
  63. package/src/client/components/editors/TabsEditor.tsx +2 -4
  64. package/src/client/components/index.ts +3 -0
  65. package/src/client/loader.ts +3 -0
  66. package/src/core/diff.ts +145 -0
  67. package/src/core/excerpt.ts +159 -0
  68. package/src/core/preview.ts +87 -0
  69. package/src/core/registry.ts +0 -39
  70. package/src/core/types/blocks.ts +83 -6
  71. package/src/core/types/bylines.ts +14 -0
  72. package/src/core/types/media.ts +13 -0
  73. package/src/core/types/taxonomies.ts +20 -0
  74. package/src/cron/scheduled.ts +57 -0
  75. package/src/docs/agent.ts +116 -0
  76. package/src/docs/config.ts +25 -0
  77. package/src/docs/index.ts +5 -0
  78. package/src/docs/routing.ts +104 -0
  79. package/src/docs/sidebar.ts +251 -0
  80. package/src/docs/toc.ts +47 -0
  81. package/src/env.d.ts +13 -0
  82. package/src/index.ts +40 -6
  83. package/src/integration.ts +196 -0
  84. package/src/loader/live.ts +254 -0
  85. package/src/markdown/index.ts +0 -1
  86. package/src/mcp/index.ts +228 -0
  87. package/src/schema/bylines.ts +23 -0
  88. package/src/schema/index.ts +3 -0
  89. package/src/schema/page_versions.ts +2 -0
  90. package/src/schema/pages.ts +2 -0
  91. package/src/schema/site_settings.ts +41 -0
  92. package/src/schema/taxonomies.ts +49 -0
  93. package/src/services/bylines.ts +74 -0
  94. package/src/services/search-indexer.ts +61 -0
  95. package/src/services/site-settings.ts +108 -0
  96. package/src/services/taxonomies.ts +276 -0
  97. package/src/storage/index.ts +253 -0
  98. package/src/astro/blocks/DialogueBlock.astro +0 -23
  99. package/src/astro/blocks/SceneBlock.astro +0 -65
  100. package/src/astro/blocks/ScriptBlock.astro +0 -59
  101. package/src/core/template-sync.ts +0 -75
  102. package/src/markdown/parser.ts +0 -347
  103. package/src/migration.ts +0 -155
  104. package/src/search/sync.ts +0 -32
@@ -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-foreground uppercase tracking-widest">
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-foreground uppercase tracking-widest">
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-foreground uppercase tracking-widest">
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-foreground italic py-0.5">No characters added</div>
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-foreground uppercase tracking-widest">Plots</span>
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-foreground uppercase tracking-widest">
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-foreground uppercase tracking-widest block mb-1">
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-foreground">Empty script content</div>
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-foreground select-none shrink-0 tracking-wider">
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-foreground placeholder:text-muted-foreground/60 py-0.5"
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-foreground bg-transparent border-0 focus:outline-none placeholder:text-muted-foreground/50 py-0.5"
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-foreground">Empty section</span>
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-background"
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-foreground block">Empty step content</span>
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-foreground">
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-foreground block mb-2">No steps in walkthrough</span>
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-foreground uppercase">Tab Label:</span>
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-background"
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-foreground block">Empty tab content</span>
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-foreground">Table</span>
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-foreground"
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-foreground hover:text-danger p-1 text-xs"
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-foreground"
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-foreground hover:text-danger p-1 text-xs"
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-foreground">
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-foreground block mb-2">No tabs in container</span>
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"
@@ -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"
@@ -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
+ }