@rimelight/cms 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. package/README.md +19 -0
  2. package/package.json +69 -0
  3. package/src/astro/BlockRenderer.astro +111 -0
  4. package/src/astro/PageRenderer.astro +23 -0
  5. package/src/astro/blocks/CalloutBlock.astro +78 -0
  6. package/src/astro/blocks/CardBlock.astro +43 -0
  7. package/src/astro/blocks/CardsBlock.astro +24 -0
  8. package/src/astro/blocks/CodeBlock.astro +16 -0
  9. package/src/astro/blocks/DialogueBlock.astro +23 -0
  10. package/src/astro/blocks/FileTreeBlock.astro +45 -0
  11. package/src/astro/blocks/ImageBlock.astro +17 -0
  12. package/src/astro/blocks/OrderedListBlock.astro +55 -0
  13. package/src/astro/blocks/ParagraphBlock.astro +65 -0
  14. package/src/astro/blocks/SceneBlock.astro +65 -0
  15. package/src/astro/blocks/ScriptBlock.astro +59 -0
  16. package/src/astro/blocks/SectionBlock.astro +46 -0
  17. package/src/astro/blocks/StepItemBlock.astro +25 -0
  18. package/src/astro/blocks/StepsBlock.astro +14 -0
  19. package/src/astro/blocks/TabItemBlock.astro +19 -0
  20. package/src/astro/blocks/TableBlock.astro +49 -0
  21. package/src/astro/blocks/TabsBlock.astro +67 -0
  22. package/src/astro/blocks/UnorderedListBlock.astro +55 -0
  23. package/src/auth/editor-guards.ts +148 -0
  24. package/src/auth/filter-blocks.ts +44 -0
  25. package/src/auth/filter-templates.ts +81 -0
  26. package/src/auth/permissions.ts +40 -0
  27. package/src/cache/purge.ts +37 -0
  28. package/src/client/components/RimelightBlock.tsx +487 -0
  29. package/src/client/components/RimelightBlockPicker.tsx +305 -0
  30. package/src/client/components/RimelightEditor.tsx +131 -0
  31. package/src/client/components/RimelightEditorLayout.tsx +143 -0
  32. package/src/client/components/RimelightPreview.tsx +354 -0
  33. package/src/client/components/RimelightPropertiesPanel.tsx +408 -0
  34. package/src/client/components/RimelightTemplateEditor.tsx +245 -0
  35. package/src/client/components/editors/CalloutEditor.tsx +185 -0
  36. package/src/client/components/editors/CardEditor.tsx +60 -0
  37. package/src/client/components/editors/CardsEditor.tsx +132 -0
  38. package/src/client/components/editors/CodeEditor.tsx +101 -0
  39. package/src/client/components/editors/DialogueEditor.tsx +52 -0
  40. package/src/client/components/editors/FileTreeEditor.tsx +65 -0
  41. package/src/client/components/editors/ImageEditor.tsx +70 -0
  42. package/src/client/components/editors/ParagraphEditor.tsx +314 -0
  43. package/src/client/components/editors/SceneEditor.tsx +245 -0
  44. package/src/client/components/editors/ScriptEditor.tsx +245 -0
  45. package/src/client/components/editors/SectionEditor.tsx +139 -0
  46. package/src/client/components/editors/StepItemEditor.tsx +117 -0
  47. package/src/client/components/editors/StepsEditor.tsx +105 -0
  48. package/src/client/components/editors/TabItemEditor.tsx +113 -0
  49. package/src/client/components/editors/TableEditor.tsx +181 -0
  50. package/src/client/components/editors/TabsEditor.tsx +105 -0
  51. package/src/client/components/index.ts +7 -0
  52. package/src/client/loader.ts +19 -0
  53. package/src/client/state/autosave.ts +122 -0
  54. package/src/client/state/editor-store.ts +411 -0
  55. package/src/client/state/history.ts +251 -0
  56. package/src/client/state/lock-manager.ts +89 -0
  57. package/src/client/styles/cms-editor.css +653 -0
  58. package/src/client/toast.ts +12 -0
  59. package/src/client/utils/sortable.ts +184 -0
  60. package/src/client/utils/splitpane.ts +87 -0
  61. package/src/client/utils/tree-sanitizer.ts +33 -0
  62. package/src/core/page-definitions.ts +502 -0
  63. package/src/core/registry.ts +99 -0
  64. package/src/core/template-sync.ts +75 -0
  65. package/src/core/types/blocks.ts +367 -0
  66. package/src/core/types/inline.ts +23 -0
  67. package/src/core/types/pages.ts +36 -0
  68. package/src/core/types/templates.ts +32 -0
  69. package/src/core/types/versioning.ts +44 -0
  70. package/src/core/validator.ts +28 -0
  71. package/src/env.d.ts +4 -0
  72. package/src/index.ts +47 -0
  73. package/src/markdown/index.ts +2 -0
  74. package/src/markdown/parser.ts +347 -0
  75. package/src/markdown/serializer.ts +219 -0
  76. package/src/migration.ts +155 -0
  77. package/src/schema/index.ts +8 -0
  78. package/src/schema/page_draft_locks.ts +13 -0
  79. package/src/schema/page_drafts.ts +18 -0
  80. package/src/schema/page_templates.ts +27 -0
  81. package/src/schema/page_version_approvals.ts +12 -0
  82. package/src/schema/page_version_comments.ts +14 -0
  83. package/src/schema/page_versions.ts +34 -0
  84. package/src/schema/pages.ts +42 -0
  85. package/src/schema/search.ts +23 -0
  86. package/src/search/query.ts +10 -0
  87. package/src/search/sync.ts +32 -0
@@ -0,0 +1,502 @@
1
+ /**
2
+ * Page type definitions for the CMS.
3
+ *
4
+ * Ported from rimelight.com/src/types/pageDefinitions.ts. Each definition specifies typed property
5
+ * groups with field schemas and optional initial template blocks seeded on page creation.
6
+ */
7
+
8
+ import type { BaseBlock } from "./types/blocks"
9
+
10
+ // ─── Field type definitions ───────────────────────────────────────────────────
11
+
12
+ export type FieldType = "text" | "number" | "enum" | "text-array" | "page" | "page-array"
13
+
14
+ export interface FieldDefinition {
15
+ type: FieldType
16
+ label: { en: string }
17
+ defaultValue: any
18
+ options?: { en: string }[]
19
+ allowedPageTypes?: string[]
20
+ }
21
+
22
+ export interface PropertyGroup {
23
+ label: { en: string }
24
+ defaultOpen: boolean
25
+ fields: Record<string, FieldDefinition>
26
+ }
27
+
28
+ export interface PageDefinition {
29
+ typeLabelKey: string
30
+ properties: Record<string, PropertyGroup>
31
+ /**
32
+ * Factory that returns the initial blocks seeded when a page of this type is created
33
+ */
34
+ initialBlocks?: () => BaseBlock[]
35
+ }
36
+
37
+ function uuid(): string {
38
+ return typeof crypto !== "undefined" && crypto.randomUUID
39
+ ? crypto.randomUUID()
40
+ : Math.random().toString(36).slice(2, 11)
41
+ }
42
+
43
+ export function definePageDefinition(def: PageDefinition): PageDefinition {
44
+ return def
45
+ }
46
+
47
+ // ─── Standard base definitions ────────────────────────────────────────────────
48
+
49
+ export const DOCUMENT_DEFINITION = definePageDefinition({
50
+ typeLabelKey: "page.type.document",
51
+ properties: {}
52
+ })
53
+
54
+ export const BLOG_POST_DEFINITION = definePageDefinition({
55
+ typeLabelKey: "page.type.blogPost",
56
+ properties: {
57
+ meta: {
58
+ label: { en: "Post Metadata" },
59
+ defaultOpen: true,
60
+ fields: {
61
+ category: {
62
+ type: "enum",
63
+ label: { en: "Category" },
64
+ defaultValue: { en: "Company News" },
65
+ options: [{ en: "Company News" }, { en: "Development Log" }, { en: "New Release" }]
66
+ },
67
+ publishedAt: {
68
+ type: "text",
69
+ label: { en: "Published At" },
70
+ defaultValue: { en: "" }
71
+ },
72
+ author: {
73
+ type: "text",
74
+ label: { en: "Author" },
75
+ defaultValue: { en: "" }
76
+ }
77
+ }
78
+ }
79
+ }
80
+ })
81
+
82
+ export const PATCH_NOTE_DEFINITION = definePageDefinition({
83
+ typeLabelKey: "page.type.patchNote",
84
+ properties: {
85
+ version: {
86
+ label: { en: "Version Info" },
87
+ defaultOpen: true,
88
+ fields: {
89
+ versionNumber: {
90
+ type: "text",
91
+ label: { en: "Version Number" },
92
+ defaultValue: "1.0.0"
93
+ },
94
+ releaseDate: {
95
+ type: "text",
96
+ label: { en: "Release Date" },
97
+ defaultValue: { en: "" }
98
+ }
99
+ }
100
+ }
101
+ }
102
+ })
103
+
104
+ export const LOCATION_DEFINITION = definePageDefinition({
105
+ typeLabelKey: "page.type.location",
106
+ properties: {
107
+ geography: {
108
+ label: { en: "Geography" },
109
+ defaultOpen: true,
110
+ fields: {
111
+ region: { type: "text", label: { en: "Region" }, defaultValue: { en: "" } },
112
+ climate: { type: "text", label: { en: "Climate" }, defaultValue: "Temperate" }
113
+ }
114
+ }
115
+ }
116
+ })
117
+
118
+ export const SPECIES_DEFINITION = definePageDefinition({
119
+ typeLabelKey: "page.type.species",
120
+ properties: {
121
+ biology: {
122
+ label: { en: "Biology" },
123
+ defaultOpen: true,
124
+ fields: {
125
+ lifespan: { type: "text", label: { en: "Average Lifespan" }, defaultValue: { en: "" } },
126
+ homeworld: {
127
+ type: "page",
128
+ label: { en: "Homeworld" },
129
+ defaultValue: "",
130
+ allowedPageTypes: ["Location"]
131
+ }
132
+ }
133
+ }
134
+ }
135
+ })
136
+
137
+ export const CHARACTER_DEFINITION = definePageDefinition({
138
+ typeLabelKey: "page.type.character",
139
+ properties: {
140
+ identity: {
141
+ label: { en: "Identity" },
142
+ defaultOpen: true,
143
+ fields: {
144
+ name: { type: "text", label: { en: "Name" }, defaultValue: { en: "" } },
145
+ title: { type: "text", label: { en: "Title" }, defaultValue: { en: "" } },
146
+ aliases: { type: "text-array", label: { en: "Aliases" }, defaultValue: [] },
147
+ pronouns: {
148
+ type: "enum",
149
+ label: { en: "Pronouns" },
150
+ defaultValue: { en: "Unknown" },
151
+ options: [
152
+ { en: "He/Him" },
153
+ { en: "She/Her" },
154
+ { en: "They/Them" },
155
+ { en: "He/They" },
156
+ { en: "She/They" },
157
+ { en: "Other" },
158
+ { en: "Unknown" }
159
+ ]
160
+ },
161
+ flavourText: { type: "text", label: { en: "Flavour Text" }, defaultValue: { en: "" } }
162
+ }
163
+ },
164
+ characteristics: {
165
+ label: { en: "Characteristics" },
166
+ defaultOpen: true,
167
+ fields: {
168
+ species: {
169
+ type: "page",
170
+ label: { en: "Species" },
171
+ defaultValue: "",
172
+ allowedPageTypes: ["Species"]
173
+ },
174
+ sex: {
175
+ type: "enum",
176
+ label: { en: "Sex" },
177
+ defaultValue: { en: "Unknown" },
178
+ options: [{ en: "Male" }, { en: "Female" }, { en: "Other" }, { en: "Unknown" }]
179
+ },
180
+ height: { type: "number", label: { en: "Height" }, defaultValue: 0 },
181
+ weight: { type: "number", label: { en: "Weight" }, defaultValue: 0 },
182
+ dateOfBirth: { type: "text", label: { en: "Date of Birth" }, defaultValue: { en: "" } },
183
+ placeOfBirth: { type: "text", label: { en: "Place of Birth" }, defaultValue: { en: "" } },
184
+ dateOfDeath: { type: "text", label: { en: "Date of Death" }, defaultValue: { en: "" } },
185
+ placeOfDeath: { type: "text", label: { en: "Place of Death" }, defaultValue: { en: "" } }
186
+ }
187
+ },
188
+ inventory: {
189
+ label: { en: "Inventory & Assets" },
190
+ defaultOpen: false,
191
+ fields: {
192
+ equipment: {
193
+ type: "page-array",
194
+ label: { en: "Equipment" },
195
+ defaultValue: [],
196
+ allowedPageTypes: ["Object"]
197
+ },
198
+ pets: {
199
+ type: "page-array",
200
+ label: { en: "Pets" },
201
+ defaultValue: [],
202
+ allowedPageTypes: ["Character"]
203
+ },
204
+ mounts: {
205
+ type: "page-array",
206
+ label: { en: "Mounts" },
207
+ defaultValue: [],
208
+ allowedPageTypes: ["Character"]
209
+ }
210
+ }
211
+ },
212
+ affiliations: {
213
+ label: { en: "Affiliations" },
214
+ defaultOpen: false,
215
+ fields: {
216
+ currentAffiliations: {
217
+ type: "page-array",
218
+ label: { en: "Current Affiliations" },
219
+ defaultValue: [],
220
+ allowedPageTypes: ["Group"]
221
+ },
222
+ formerAffiliations: {
223
+ type: "page-array",
224
+ label: { en: "Former Affiliations" },
225
+ defaultValue: [],
226
+ allowedPageTypes: ["Group"]
227
+ }
228
+ }
229
+ },
230
+ other: {
231
+ label: { en: "Other" },
232
+ defaultOpen: false,
233
+ fields: {
234
+ favouriteFood: {
235
+ type: "page",
236
+ label: { en: "Favourite Food" },
237
+ defaultValue: "",
238
+ allowedPageTypes: ["Object"]
239
+ }
240
+ }
241
+ }
242
+ },
243
+ initialBlocks: () => [
244
+ {
245
+ id: uuid(),
246
+ type: "SectionBlock",
247
+ props: { level: 2, title: "Appearance", children: [] },
248
+ isTemplated: true
249
+ },
250
+ {
251
+ id: uuid(),
252
+ type: "SectionBlock",
253
+ props: { level: 2, title: "Abilities", children: [] },
254
+ isTemplated: true
255
+ },
256
+ {
257
+ id: uuid(),
258
+ type: "SectionBlock",
259
+ props: { level: 2, title: "History", children: [] },
260
+ isTemplated: true
261
+ }
262
+ ]
263
+ })
264
+
265
+ export const SKILL_DEFINITION = definePageDefinition({
266
+ typeLabelKey: "page.type.skill",
267
+ properties: {
268
+ mechanics: {
269
+ label: { en: "Mechanics" },
270
+ defaultOpen: true,
271
+ fields: {
272
+ cooldown: { type: "number", label: { en: "Cooldown (sec)" }, defaultValue: 10 },
273
+ manaCost: { type: "number", label: { en: "Mana Cost" }, defaultValue: 50 },
274
+ damageType: {
275
+ type: "enum",
276
+ label: { en: "Damage Type" },
277
+ defaultValue: { en: "Physical" },
278
+ options: [{ en: "Physical" }, { en: "Magic" }, { en: "True" }, { en: "None" }]
279
+ }
280
+ }
281
+ }
282
+ }
283
+ })
284
+
285
+ export const ITEM_DEFINITION = definePageDefinition({
286
+ typeLabelKey: "page.type.item",
287
+ properties: {
288
+ details: {
289
+ label: { en: "Item Details" },
290
+ defaultOpen: true,
291
+ fields: {
292
+ rarity: {
293
+ type: "enum",
294
+ label: { en: "Rarity" },
295
+ defaultValue: { en: "Common" },
296
+ options: [
297
+ { en: "Common" },
298
+ { en: "Uncommon" },
299
+ { en: "Rare" },
300
+ { en: "Epic" },
301
+ { en: "Legendary" }
302
+ ]
303
+ },
304
+ price: { type: "number", label: { en: "Gold Price" }, defaultValue: 100 },
305
+ isQuestItem: {
306
+ type: "enum",
307
+ label: { en: "Quest Item" },
308
+ defaultValue: { en: "No" },
309
+ options: [{ en: "Yes" }, { en: "No" }]
310
+ }
311
+ }
312
+ }
313
+ }
314
+ })
315
+
316
+ export const OBJECT_DEFINITION = definePageDefinition({
317
+ typeLabelKey: "page.type.object",
318
+ properties: {
319
+ details: {
320
+ label: { en: "Object Details" },
321
+ defaultOpen: true,
322
+ fields: {
323
+ category: { type: "text", label: { en: "Category" }, defaultValue: { en: "Miscellaneous" } }
324
+ }
325
+ }
326
+ }
327
+ })
328
+
329
+ export const GROUP_DEFINITION = definePageDefinition({
330
+ typeLabelKey: "page.type.group",
331
+ properties: {
332
+ details: {
333
+ label: { en: "Group Details" },
334
+ defaultOpen: true,
335
+ fields: {
336
+ leader: {
337
+ type: "page",
338
+ label: { en: "Leader" },
339
+ defaultValue: "",
340
+ allowedPageTypes: ["Character"]
341
+ },
342
+ headquarters: {
343
+ type: "page",
344
+ label: { en: "Headquarters" },
345
+ defaultValue: "",
346
+ allowedPageTypes: ["Location"]
347
+ }
348
+ }
349
+ }
350
+ }
351
+ })
352
+
353
+ export const HERO_DEFINITION = definePageDefinition({
354
+ typeLabelKey: "page.type.hero",
355
+ properties: {
356
+ combat: {
357
+ label: { en: "Combat Stats" },
358
+ defaultOpen: true,
359
+ fields: {
360
+ class: {
361
+ type: "enum",
362
+ label: { en: "Class" },
363
+ defaultValue: { en: "Warrior" },
364
+ options: [{ en: "Warrior" }, { en: "Mage" }, { en: "Rogue" }, { en: "Paladin" }]
365
+ },
366
+ difficulty: { type: "number", label: { en: "Difficulty" }, defaultValue: 1 },
367
+ primaryRole: {
368
+ type: "enum",
369
+ label: { en: "Primary Role" },
370
+ defaultValue: { en: "Tank" },
371
+ options: [{ en: "Tank" }, { en: "DPS" }, { en: "Support" }]
372
+ }
373
+ }
374
+ },
375
+ progression: {
376
+ label: { en: "Progression" },
377
+ defaultOpen: true,
378
+ fields: {
379
+ baseHp: { type: "number", label: { en: "Base HP" }, defaultValue: 500 },
380
+ baseMana: { type: "number", label: { en: "Base Mana" }, defaultValue: 100 }
381
+ }
382
+ }
383
+ },
384
+ initialBlocks: () => [
385
+ {
386
+ id: uuid(),
387
+ type: "SectionBlock",
388
+ props: { level: 2, title: "Playstyle", children: [] },
389
+ isTemplated: true
390
+ },
391
+ {
392
+ id: uuid(),
393
+ type: "SectionBlock",
394
+ props: { level: 2, title: "Background Lore", children: [] },
395
+ isTemplated: true
396
+ }
397
+ ]
398
+ })
399
+
400
+ export const CARD_DEFINITION = definePageDefinition({
401
+ typeLabelKey: "page.type.card",
402
+ properties: {},
403
+ initialBlocks: () => []
404
+ })
405
+
406
+ export const SERIES_DEFINITION = definePageDefinition({
407
+ typeLabelKey: "page.type.series",
408
+ properties: {
409
+ details: {
410
+ label: { en: "Details" },
411
+ defaultOpen: true,
412
+ fields: {
413
+ genre: { type: "text", label: { en: "Genre" }, defaultValue: { en: "Fantasy" } },
414
+ status: {
415
+ type: "enum",
416
+ label: { en: "Status" },
417
+ defaultValue: { en: "In Development" },
418
+ options: [
419
+ { en: "In Development" },
420
+ { en: "Ongoing" },
421
+ { en: "Completed" },
422
+ { en: "Cancelled" }
423
+ ]
424
+ },
425
+ seasons: { type: "number", label: { en: "Seasons" }, defaultValue: 1 }
426
+ }
427
+ }
428
+ },
429
+ initialBlocks: () => [
430
+ {
431
+ id: uuid(),
432
+ type: "SectionBlock",
433
+ props: { level: 2, title: "Overview", children: [] },
434
+ isTemplated: true
435
+ },
436
+ {
437
+ id: uuid(),
438
+ type: "SectionBlock",
439
+ props: { level: 2, title: "Seasons", children: [] },
440
+ isTemplated: true
441
+ }
442
+ ]
443
+ })
444
+
445
+ // ─── Page Map ─────────────────────────────────────────────────────────────────
446
+
447
+ export const PAGE_MAP: Record<string, PageDefinition> = {
448
+ Document: DOCUMENT_DEFINITION,
449
+ BlogPost: BLOG_POST_DEFINITION,
450
+ PatchNote: PATCH_NOTE_DEFINITION,
451
+ Location: LOCATION_DEFINITION,
452
+ Species: SPECIES_DEFINITION,
453
+ Character: CHARACTER_DEFINITION,
454
+ Skill: SKILL_DEFINITION,
455
+ Item: ITEM_DEFINITION,
456
+ Object: OBJECT_DEFINITION,
457
+ Group: GROUP_DEFINITION,
458
+ Card: CARD_DEFINITION,
459
+ Hero: HERO_DEFINITION,
460
+ Series: SERIES_DEFINITION,
461
+ Tale: definePageDefinition({ typeLabelKey: "page.type.tale", properties: {} }),
462
+ Episode: definePageDefinition({ typeLabelKey: "page.type.episode", properties: {} }),
463
+ Default: definePageDefinition({ typeLabelKey: "page.type.default", properties: {} })
464
+ }
465
+
466
+ export function getPageDefinition(type: string): PageDefinition {
467
+ if (!type) return PAGE_MAP.Default!
468
+
469
+ // Exact match
470
+ if (PAGE_MAP[type]) return PAGE_MAP[type]
471
+
472
+ // Case-insensitive / normalized lookup
473
+ const normalized = type.toLowerCase().replace(/[^a-z0-9]/g, "")
474
+
475
+ const ALIAS_MAP: Record<string, PageDefinition> = {
476
+ blog: BLOG_POST_DEFINITION,
477
+ blogpost: BLOG_POST_DEFINITION,
478
+ article: BLOG_POST_DEFINITION,
479
+ doc: DOCUMENT_DEFINITION,
480
+ document: DOCUMENT_DEFINITION,
481
+ patchnote: PATCH_NOTE_DEFINITION,
482
+ location: LOCATION_DEFINITION,
483
+ species: SPECIES_DEFINITION,
484
+ character: CHARACTER_DEFINITION,
485
+ skill: SKILL_DEFINITION,
486
+ item: ITEM_DEFINITION,
487
+ object: OBJECT_DEFINITION,
488
+ group: GROUP_DEFINITION,
489
+ card: CARD_DEFINITION,
490
+ hero: HERO_DEFINITION,
491
+ series: SERIES_DEFINITION
492
+ }
493
+
494
+ if (ALIAS_MAP[normalized]) return ALIAS_MAP[normalized]
495
+
496
+ // Fallback scan
497
+ for (const [key, def] of Object.entries(PAGE_MAP)) {
498
+ if (key.toLowerCase() === normalized) return def
499
+ }
500
+
501
+ return PAGE_MAP.Default!
502
+ }
@@ -0,0 +1,99 @@
1
+ import type { TimeOfDay, Setting, Transition } from "./types/blocks"
2
+
3
+ export interface BlockDefinition {
4
+ type: string
5
+ label: string
6
+ description?: string
7
+ defaultProps: Record<string, any>
8
+ allowChildren?: boolean
9
+ }
10
+
11
+ const definitions = new Map<string, BlockDefinition>()
12
+
13
+ export const BlockRegistry = {
14
+ register(def: BlockDefinition): void {
15
+ definitions.set(def.type, def)
16
+ },
17
+
18
+ get(type: string): BlockDefinition | undefined {
19
+ return definitions.get(type)
20
+ },
21
+
22
+ getAll(): BlockDefinition[] {
23
+ return Array.from(definitions.values())
24
+ }
25
+ }
26
+
27
+ // Register built-in block types
28
+ BlockRegistry.register({
29
+ type: "SectionBlock",
30
+ label: "Section",
31
+ description: "Container block for grouping nested content",
32
+ defaultProps: { title: "" },
33
+ allowChildren: true
34
+ })
35
+
36
+ BlockRegistry.register({
37
+ type: "ParagraphBlock",
38
+ label: "Paragraph",
39
+ description: "Rich text paragraph block",
40
+ defaultProps: { text: { en: "" } }
41
+ })
42
+
43
+ BlockRegistry.register({
44
+ type: "CalloutBlock",
45
+ label: "Callout",
46
+ description: "Highlighted callout box (info, warning, note)",
47
+ defaultProps: { variant: "info", text: { en: "" } }
48
+ })
49
+
50
+ BlockRegistry.register({
51
+ type: "ImageBlock",
52
+ label: "Image",
53
+ description: "Image component with caption and alt text",
54
+ defaultProps: { url: "", alt: "", caption: "" }
55
+ })
56
+
57
+ BlockRegistry.register({
58
+ type: "CodeBlock",
59
+ label: "Code",
60
+ description: "Code block with syntax highlighting",
61
+ defaultProps: { code: "", language: "typescript" }
62
+ })
63
+
64
+ BlockRegistry.register({
65
+ type: "ScriptBlock",
66
+ label: "Script",
67
+ description: "Script block with title, synopsis, characters, and plot tracking",
68
+ defaultProps: {
69
+ title: "",
70
+ synopsis: "",
71
+ characters: [],
72
+ aPlot: "",
73
+ bPlot: "",
74
+ cPlot: "",
75
+ stinger: ""
76
+ },
77
+ allowChildren: true
78
+ })
79
+
80
+ BlockRegistry.register({
81
+ type: "SceneBlock",
82
+ label: "Scene",
83
+ description: "Scene block with location, time, setting, and transition",
84
+ defaultProps: {
85
+ location: "",
86
+ timeOfDay: "OTHER" as TimeOfDay,
87
+ setting: "OTHER" as Setting,
88
+ transition: "NONE" as Transition,
89
+ description: ""
90
+ },
91
+ allowChildren: true
92
+ })
93
+
94
+ BlockRegistry.register({
95
+ type: "DialogueBlock",
96
+ label: "Dialogue",
97
+ description: "Dialogue block with character, parenthetical, and line",
98
+ defaultProps: { character: "", parenthetical: "", line: "" }
99
+ })
@@ -0,0 +1,75 @@
1
+ import type { BaseBlock } from "./types/blocks"
2
+
3
+ /**
4
+ * Merges updated template blocks into an existing page block tree. Matches templated blocks by
5
+ * block `type` + `isTemplated: true` tag and structural index. Fits updated template anchors while
6
+ * preserving user-inserted custom blocks around them.
7
+ */
8
+ export function mergeTemplateBlocks(
9
+ existingPageBlocks: BaseBlock[],
10
+ newTemplateBlocks: BaseBlock[]
11
+ ): BaseBlock[] {
12
+ const merged: BaseBlock[] = []
13
+
14
+ const newTemplatedAnchors = newTemplateBlocks.filter((b) => b.isTemplated)
15
+ let existingIndex = 0
16
+
17
+ for (const templateAnchor of newTemplatedAnchors) {
18
+ // Collect user-inserted custom blocks preceding this template anchor
19
+ while (
20
+ existingIndex < existingPageBlocks.length &&
21
+ !existingPageBlocks[existingIndex]?.isTemplated
22
+ ) {
23
+ const block = existingPageBlocks[existingIndex]
24
+ if (block) merged.push(block)
25
+ existingIndex++
26
+ }
27
+
28
+ // Match existing template anchor by block type
29
+ const existingAnchor = existingPageBlocks[existingIndex]
30
+
31
+ if (existingAnchor && existingAnchor.type === templateAnchor.type) {
32
+ merged.push({
33
+ ...templateAnchor,
34
+ id: existingAnchor.id,
35
+ props: mergeBlockProps(
36
+ existingAnchor.props,
37
+ templateAnchor.props,
38
+ existingAnchor.templatedProps
39
+ )
40
+ })
41
+ existingIndex++
42
+ } else {
43
+ // New template anchor inserted in template update: add fresh block with new random UUID
44
+ merged.push({
45
+ ...templateAnchor,
46
+ id: crypto.randomUUID()
47
+ })
48
+ }
49
+ }
50
+
51
+ // Append any remaining custom user blocks at the end of the document
52
+ while (existingIndex < existingPageBlocks.length) {
53
+ const block = existingPageBlocks[existingIndex]
54
+ if (block) merged.push(block)
55
+ existingIndex++
56
+ }
57
+
58
+ return merged
59
+ }
60
+
61
+ function mergeBlockProps(
62
+ existingProps: any,
63
+ templateProps: any,
64
+ templatedProps?: boolean | string[]
65
+ ) {
66
+ if (templatedProps === true) return { ...templateProps }
67
+ if (Array.isArray(templatedProps)) {
68
+ const updated = { ...existingProps }
69
+ for (const key of templatedProps) {
70
+ updated[key] = templateProps[key]
71
+ }
72
+ return updated
73
+ }
74
+ return { ...existingProps, ...templateProps }
75
+ }