rivet_cms 0.1.2.pre → 0.3.0

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 (127) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +53 -0
  3. data/COPYING +674 -0
  4. data/COPYING.LESSER +165 -0
  5. data/README.md +173 -10
  6. data/app/assets/builds/rivet_cms.css +2 -2
  7. data/app/assets/builds/rivet_cms.js +395 -9505
  8. data/app/assets/stylesheets/rivet_cms/application.tailwind.css +145 -22
  9. data/app/controllers/concerns/rivet_cms/inertia_props.rb +239 -0
  10. data/app/controllers/rivet_cms/api_docs_controller.rb +47 -0
  11. data/app/controllers/rivet_cms/api_tokens_controller.rb +43 -0
  12. data/app/controllers/rivet_cms/application_controller.rb +220 -2
  13. data/app/controllers/rivet_cms/auth_controller.rb +28 -0
  14. data/app/controllers/rivet_cms/components_controller.rb +93 -2
  15. data/app/controllers/rivet_cms/content_controller.rb +105 -0
  16. data/app/controllers/rivet_cms/content_manager_controller.rb +32 -0
  17. data/app/controllers/rivet_cms/content_types_controller.rb +118 -26
  18. data/app/controllers/rivet_cms/dashboard_controller.rb +55 -5
  19. data/app/controllers/rivet_cms/documents_controller.rb +204 -0
  20. data/app/controllers/rivet_cms/fields_controller.rb +77 -74
  21. data/app/controllers/rivet_cms/invitations_controller.rb +36 -0
  22. data/app/controllers/rivet_cms/media_assets_controller.rb +76 -0
  23. data/app/controllers/rivet_cms/sessions_controller.rb +122 -0
  24. data/app/controllers/rivet_cms/setup_controller.rb +60 -0
  25. data/app/controllers/rivet_cms/trash_controller.rb +68 -0
  26. data/app/controllers/rivet_cms/users_controller.rb +117 -0
  27. data/app/helpers/rivet_cms/application_helper.rb +13 -3
  28. data/app/helpers/rivet_cms/components_helper.rb +4 -0
  29. data/app/helpers/rivet_cms/content_types_helper.rb +4 -0
  30. data/app/helpers/rivet_cms/dashboard_helper.rb +4 -0
  31. data/app/jobs/rivet_cms/webhook_delivery_job.rb +35 -0
  32. data/app/models/concerns/rivet_cms/has_fields.rb +31 -0
  33. data/app/models/concerns/rivet_cms/organization_scoped.rb +17 -0
  34. data/app/models/concerns/rivet_cms/revision_owned.rb +36 -0
  35. data/app/models/concerns/rivet_cms/sluggable.rb +38 -0
  36. data/app/models/concerns/rivet_cms/soft_deletable.rb +42 -0
  37. data/app/models/concerns/rivet_cms/typed_value.rb +51 -0
  38. data/app/models/rivet_cms/api_token.rb +64 -0
  39. data/app/models/rivet_cms/category.rb +20 -0
  40. data/app/models/rivet_cms/component.rb +10 -0
  41. data/app/models/rivet_cms/component_instance.rb +27 -0
  42. data/app/models/rivet_cms/content_type.rb +36 -23
  43. data/app/models/rivet_cms/content_value.rb +19 -0
  44. data/app/models/rivet_cms/current.rb +6 -0
  45. data/app/models/rivet_cms/document.rb +71 -0
  46. data/app/models/rivet_cms/document_revision.rb +128 -0
  47. data/app/models/rivet_cms/entry.rb +73 -0
  48. data/app/models/rivet_cms/entry_collection.rb +41 -0
  49. data/app/models/rivet_cms/field.rb +203 -61
  50. data/app/models/rivet_cms/media_asset.rb +109 -0
  51. data/app/models/rivet_cms/organization.rb +46 -0
  52. data/app/models/rivet_cms/relation.rb +23 -0
  53. data/app/models/rivet_cms/user.rb +61 -0
  54. data/app/serializers/rivet_cms/revision_preloader.rb +146 -0
  55. data/app/serializers/rivet_cms/revision_serializer.rb +123 -0
  56. data/app/services/rivet_cms/content_query.rb +158 -0
  57. data/app/services/rivet_cms/content_validator.rb +150 -0
  58. data/app/services/rivet_cms/draft_writer.rb +74 -0
  59. data/app/services/rivet_cms/open_api_generator.rb +173 -221
  60. data/app/services/rivet_cms/revision_pruner.rb +64 -0
  61. data/app/services/rivet_cms/webhooks.rb +61 -0
  62. data/app/views/layouts/rivet_cms/application.html.erb +35 -42
  63. data/config/routes.rb +68 -45
  64. data/db/migrate/20251119193607_create_rivet_cms_organizations.rb +17 -0
  65. data/db/migrate/20251120050818_create_rivet_cms_content_types.rb +14 -0
  66. data/db/migrate/20251121020625_create_rivet_cms_components.rb +14 -0
  67. data/db/migrate/20251121040115_create_rivet_cms_categories.rb +14 -0
  68. data/db/migrate/20260119185946_create_content_system_tables.rb +95 -0
  69. data/db/migrate/20260722000001_create_rivet_cms_media_assets.rb +15 -0
  70. data/db/migrate/20260722000002_create_rivet_cms_api_tokens.rb +16 -0
  71. data/db/migrate/20260806000001_add_title_and_alt_to_rivet_cms_media_assets.rb +7 -0
  72. data/db/migrate/20260806000002_add_deleted_at_to_rivet_cms_content_types.rb +6 -0
  73. data/db/migrate/20260807000001_add_deleted_at_to_rivet_cms_documents.rb +6 -0
  74. data/db/migrate/20260808000001_create_rivet_cms_users.rb +15 -0
  75. data/db/seeds/README.md +70 -0
  76. data/db/seeds/templates/blog.rb +39 -0
  77. data/db/seeds/templates/events.rb +12 -0
  78. data/db/seeds/templates/faq.rb +9 -0
  79. data/db/seeds/templates/pages.rb +35 -0
  80. data/db/seeds/templates/site_settings.rb +22 -0
  81. data/db/seeds/templates/team.rb +11 -0
  82. data/db/seeds/templates/testimonials.rb +11 -0
  83. data/lib/generators/rivet_cms/install/install_generator.rb +13 -0
  84. data/lib/generators/rivet_cms/install/templates/initializer.rb +77 -0
  85. data/lib/rivet_cms/access_check.rb +12 -0
  86. data/lib/rivet_cms/audit.rb +43 -0
  87. data/lib/rivet_cms/engine.rb +28 -22
  88. data/lib/rivet_cms/hooks.rb +106 -0
  89. data/lib/rivet_cms/navigation.rb +111 -0
  90. data/lib/rivet_cms/safe_pattern.rb +40 -0
  91. data/lib/rivet_cms/seeds.rb +264 -0
  92. data/lib/rivet_cms/version.rb +1 -1
  93. data/lib/rivet_cms.rb +289 -32
  94. data/lib/tasks/rivet_cms_tasks.rake +18 -4
  95. metadata +138 -98
  96. data/MIT-LICENSE +0 -20
  97. data/app/assets/builds/rivet_cms.js.map +0 -7
  98. data/app/assets/stylesheets/rivet_cms/brand_colors.css +0 -168
  99. data/app/controllers/rivet_cms/api/docs_controller.rb +0 -38
  100. data/app/helpers/rivet_cms/brand_color_helper.rb +0 -71
  101. data/app/helpers/rivet_cms/flash_helper.rb +0 -37
  102. data/app/helpers/rivet_cms/sign_out_helper.rb +0 -11
  103. data/app/javascript/controllers/content_type_form_controller.js +0 -53
  104. data/app/javascript/controllers/field_layout_controller.js +0 -709
  105. data/app/javascript/rivet_cms.js +0 -29
  106. data/app/models/rivet_cms/content.rb +0 -4
  107. data/app/models/rivet_cms/field_values/base.rb +0 -11
  108. data/app/models/rivet_cms/field_values/boolean.rb +0 -4
  109. data/app/models/rivet_cms/field_values/integer.rb +0 -4
  110. data/app/models/rivet_cms/field_values/string.rb +0 -4
  111. data/app/models/rivet_cms/field_values/text.rb +0 -4
  112. data/app/views/rivet_cms/api/docs/show.html.erb +0 -47
  113. data/app/views/rivet_cms/content_types/_form.html.erb +0 -98
  114. data/app/views/rivet_cms/content_types/edit.html.erb +0 -27
  115. data/app/views/rivet_cms/content_types/index.html.erb +0 -151
  116. data/app/views/rivet_cms/content_types/new.html.erb +0 -19
  117. data/app/views/rivet_cms/content_types/show.html.erb +0 -147
  118. data/app/views/rivet_cms/dashboard/index.html.erb +0 -263
  119. data/app/views/rivet_cms/fields/_form.html.erb +0 -111
  120. data/app/views/rivet_cms/fields/edit.html.erb +0 -25
  121. data/app/views/rivet_cms/fields/index.html.erb +0 -126
  122. data/app/views/rivet_cms/fields/new.html.erb +0 -25
  123. data/app/views/rivet_cms/shared/_navigation.html.erb +0 -153
  124. data/config/i18n-tasks.yml +0 -178
  125. data/config/locales/en.yml +0 -14
  126. data/db/migrate/20250317194359_create_core_tables.rb +0 -90
  127. data/lib/rivet_cms/routes.rb +0 -49
@@ -1,245 +1,197 @@
1
1
  module RivetCms
2
+ # Builds an OpenAPI 3 document describing the delivery API for an organization's
3
+ # content types. Field types map to JSON Schema; references/components/media
4
+ # expand into nested schemas.
2
5
  class OpenApiGenerator
3
- class << self
4
- def generate
5
- Rails.cache.fetch("rivet_cms_api_docs", expires_in: 1.hour) do
6
- generate_schema
7
- end
8
- end
6
+ # content_types/components narrow the document to a permitted subset;
7
+ # nil means all. Excluded types must not be referenced either, or the
8
+ # document would carry $refs to schemas it does not contain.
9
+ def initialize(organization, base_url: "/api", content_types: nil, components: nil)
10
+ @organization = organization
11
+ @base_url = base_url
12
+ @content_types = content_types
13
+ @components = components
14
+ end
9
15
 
10
- private
11
-
12
- def generate_schema
13
- schema = {
14
- "openapi" => "3.0.1",
15
- "info" => {
16
- "title" => "RivetCMS API",
17
- "version" => "1.0.0",
18
- "description" => "API documentation for content types"
19
- },
20
- "paths" => generate_paths,
21
- "components" => {
22
- "schemas" => generate_schemas,
23
- "securitySchemes" => {
24
- "bearerAuth" => {
25
- "type" => "http",
26
- "scheme" => "bearer",
27
- "bearerFormat" => "JWT"
28
- }
29
- }
30
- },
31
- "security" => [
32
- { "bearerAuth" => [] }
33
- ]
34
- }
16
+ def as_json(*)
17
+ content_types = (@content_types || @organization.content_types.order(:name)).to_a
35
18
 
36
- deep_stringify_keys(schema)
37
- end
19
+ {
20
+ openapi: "3.0.3",
21
+ info: { title: "RivetCms Delivery API", version: "1.0.0" },
22
+ servers: [ { url: @base_url } ],
23
+ components: {
24
+ securitySchemes: { bearerAuth: { type: "http", scheme: "bearer" } },
25
+ schemas: content_types.each_with_object({}) { |ct, acc| acc[schema_name(ct)] = document_schema(ct) }
26
+ },
27
+ security: [ { bearerAuth: [] } ],
28
+ paths: content_types.each_with_object({}) { |ct, acc| add_paths(acc, ct) }
29
+ }
30
+ end
38
31
 
39
- def deep_stringify_keys(obj)
40
- case obj
41
- when Hash
42
- obj.each_with_object({}) do |(key, value), result|
43
- result[key.to_s] = deep_stringify_keys(value)
44
- end
45
- when Array
46
- obj.map { |item| deep_stringify_keys(item) }
47
- else
48
- obj
49
- end
50
- end
32
+ private
51
33
 
52
- def generate_paths
53
- paths = {}
54
-
55
- ContentType.all.each do |content_type|
56
- if content_type.is_single
57
- paths.merge!(generate_single_type_paths(content_type))
58
- else
59
- paths.merge!(generate_collection_type_paths(content_type))
60
- end
61
- end
34
+ def add_paths(paths, content_type)
35
+ item = "#{@base_url}/#{content_type.slug}"
36
+ ref = { "$ref" => "#/components/schemas/#{schema_name(content_type)}" }
62
37
 
63
- paths
64
- end
38
+ paths["#{item}"] = {
39
+ get: {
40
+ summary: "List #{content_type.name}",
41
+ parameters: list_params,
42
+ responses: { "200" => { description: "A page of documents", content: json_content(list_schema(ref)) } }
43
+ }
44
+ }
45
+ paths["#{item}/{slug}"] = {
46
+ get: {
47
+ summary: "Fetch one #{content_type.name} by slug",
48
+ parameters: [
49
+ { name: "slug", in: "path", required: true, schema: { type: "string" } },
50
+ { name: "preview", in: "query", required: false, schema: { type: "boolean" },
51
+ description: "Requires a preview-scoped token; returns the draft revision." },
52
+ populate_param,
53
+ fields_param
54
+ ],
55
+ responses: { "200" => { description: "A document", content: json_content(ref) }, "404" => { description: "Not found" } }
56
+ }
57
+ }
58
+ end
59
+
60
+ def list_params
61
+ [
62
+ { name: "page", in: "query", schema: { type: "integer", default: 1 } },
63
+ { name: "per_page", in: "query", schema: { type: "integer", default: 25, maximum: 100 } },
64
+ { name: "sort", in: "query", schema: { type: "string" },
65
+ description: "Field/column, prefix - for descending (e.g. -published_at)." },
66
+ populate_param,
67
+ fields_param
68
+ ]
69
+ end
70
+
71
+ def populate_param
72
+ { name: "populate", in: "query", schema: { type: "string" },
73
+ description: "Comma-separated reference field keys to expand one level into full documents, or * for all." }
74
+ end
75
+
76
+ def fields_param
77
+ { name: "fields", in: "query", schema: { type: "string" },
78
+ description: "Comma-separated field keys to include in data; also limits which populated fields appear." }
79
+ end
65
80
 
66
- def generate_single_type_paths(content_type)
67
- {
68
- "/api/v1/#{content_type.slug}" => {
69
- "get" => {
70
- "tags" => [content_type.name],
71
- "summary" => "Get #{content_type.name}",
72
- "responses" => {
73
- "200" => {
74
- "description" => "Returns the #{content_type.name}",
75
- "content" => {
76
- "application/json" => {
77
- "schema" => { "$ref" => "#/components/schemas/#{content_type.slug}" }
78
- }
79
- }
80
- }
81
- }
82
- }
83
- }
81
+ def list_schema(item_ref)
82
+ {
83
+ type: "object",
84
+ properties: {
85
+ data: { type: "array", items: item_ref },
86
+ meta: { type: "object", properties: {
87
+ page: { type: "integer" }, per_page: { type: "integer" },
88
+ total: { type: "integer" }, total_pages: { type: "integer" }
89
+ } }
84
90
  }
85
- end
91
+ }
92
+ end
86
93
 
87
- def generate_collection_type_paths(content_type)
88
- {
89
- "/api/v1/#{content_type.slug}" => {
90
- "get" => {
91
- "tags" => [content_type.name],
92
- "summary" => "List all #{content_type.name.pluralize}",
93
- "parameters" => [
94
- {
95
- "name" => "page",
96
- "in" => "query",
97
- "description" => "Page number for pagination",
98
- "schema" => { "type" => "integer", "default" => 1 },
99
- "required" => false
100
- },
101
- {
102
- "name" => "per_page",
103
- "in" => "query",
104
- "description" => "Number of items per page",
105
- "schema" => { "type" => "integer", "default" => 25 },
106
- "required" => false
107
- }
108
- ],
109
- "responses" => {
110
- "200" => {
111
- "description" => "Returns list of #{content_type.name.pluralize}",
112
- "content" => {
113
- "application/json" => {
114
- "schema" => {
115
- "type" => "object",
116
- "properties" => {
117
- "data" => {
118
- "type" => "array",
119
- "items" => { "$ref" => "#/components/schemas/#{content_type.slug}" }
120
- },
121
- "meta" => {
122
- "type" => "object",
123
- "properties" => {
124
- "current_page" => { "type" => "integer" },
125
- "total_pages" => { "type" => "integer" },
126
- "total_count" => { "type" => "integer" }
127
- }
128
- }
129
- }
130
- }
131
- }
132
- }
133
- }
134
- }
135
- }
136
- },
137
- "/api/v1/#{content_type.slug}/{id}" => {
138
- "get" => {
139
- "tags" => [content_type.name],
140
- "summary" => "Get a specific #{content_type.name}",
141
- "parameters" => [
142
- {
143
- "name" => "id",
144
- "in" => "path",
145
- "required" => true,
146
- "schema" => { "type" => "string" },
147
- "description" => "The ID of the #{content_type.name} to retrieve"
148
- }
149
- ],
150
- "responses" => {
151
- "200" => {
152
- "description" => "Returns the #{content_type.name}",
153
- "content" => {
154
- "application/json" => {
155
- "schema" => { "$ref" => "#/components/schemas/#{content_type.slug}" }
156
- }
157
- }
158
- },
159
- "404" => {
160
- "description" => "#{content_type.name} not found"
161
- }
162
- }
163
- }
164
- }
94
+ def document_schema(content_type)
95
+ {
96
+ type: "object",
97
+ properties: {
98
+ id: { type: "string" },
99
+ slug: { type: "string" },
100
+ content_type: { type: "string" },
101
+ state: { type: "string", enum: %w[draft published archived] },
102
+ data: { type: "object", properties: content_type.fields.kept.ordered.each_with_object({}) { |f, acc| acc[f.key] = field_schema(f) } }
165
103
  }
104
+ }
105
+ end
106
+
107
+ def field_schema(field)
108
+ case field.field_type
109
+ when "integer" then { type: "integer" }
110
+ when "decimal" then { type: "number" }
111
+ when "boolean" then { type: "boolean" }
112
+ when "date" then { type: "string", format: "date" }
113
+ when "datetime" then { type: "string", format: "date-time" }
114
+ when "enumeration" then enumeration_schema(field)
115
+ when "reference" then reference_schema(field)
116
+ when "component" then component_schema(field)
117
+ when "image", "video", "file"
118
+ { type: "object", nullable: true, properties: {
119
+ id: { type: "integer" }, filename: { type: "string" }, content_type: { type: "string" },
120
+ byte_size: { type: "integer" }, title: { type: "string", nullable: true },
121
+ alt: { type: "string", nullable: true }, description: { type: "string", nullable: true },
122
+ url: { type: "string" }
123
+ } }
124
+ when "string", "text" then string_schema(field)
125
+ else { type: "string" }
166
126
  end
127
+ end
167
128
 
168
- def generate_schemas
169
- schemas = {}
170
-
171
- ContentType.all.each do |content_type|
172
- schemas[content_type.slug] = generate_content_type_schema(content_type)
173
- end
129
+ def enumeration_schema(field)
130
+ choices = Array(field.config&.dig("choices"))
131
+ choices.any? ? { type: "string", enum: choices } : { type: "string" }
132
+ end
174
133
 
175
- schemas
176
- end
134
+ def string_schema(field)
135
+ pattern = field.config&.dig("pattern")
136
+ pattern.present? ? { type: "string", pattern: pattern } : { type: "string" }
137
+ end
177
138
 
178
- def generate_content_type_schema(content_type)
179
- {
180
- "type" => "object",
181
- "properties" => generate_properties(content_type)
182
- }
139
+ # Shallow {id, slug} by default; when the field's configured target resolves,
140
+ # a oneOf with the target schema documents the populated shape.
141
+ # A target outside the generated subset stays shallow: emitting its $ref
142
+ # would point at a schema this document does not contain.
143
+ def reference_schema(field)
144
+ shallow = { type: "object", properties: { id: { type: "string" }, slug: { type: "string" } } }
145
+ target = @organization.content_types.find_by(id: field.config&.dig("content_type_id"))
146
+ inner = target && included_type_ids.include?(target.id) ? { oneOf: [ shallow, { "$ref" => "#/components/schemas/#{schema_name(target)}" } ] } : shallow
147
+ collapse(field, inner)
148
+ end
149
+
150
+ # Same rule: a component outside the subset renders as an opaque object
151
+ # rather than leaking its field structure.
152
+ def component_schema(field)
153
+ component = Component.find_by(id: field.config&.dig("component_id"), organization_id: field.organization_id)
154
+ inner = if component && included_component_ids.include?(component.id)
155
+ { type: "object", properties: component.fields.kept.ordered.each_with_object({}) { |f, acc| acc[f.key] = field_schema(f) } }
156
+ else
157
+ { type: "object" }
183
158
  end
159
+ collapse(field, inner)
160
+ end
184
161
 
185
- def generate_properties(content_type)
186
- properties = {
187
- "id" => { "type" => "string" },
188
- "title" => { "type" => "string" },
189
- "slug" => { "type" => "string" },
190
- "status" => {
191
- "type" => "string",
192
- "enum" => ["draft", "published", "archived"]
193
- },
194
- "created_at" => { "type" => "string", "format" => "date-time" },
195
- "updated_at" => { "type" => "string", "format" => "date-time" }
196
- }
162
+ def included_type_ids
163
+ @included_type_ids ||= (@content_types || @organization.content_types).map(&:id).to_set
164
+ end
197
165
 
198
- content_type.fields.each do |field|
199
- properties[field.name] = field_type_to_schema(field)
200
- end
166
+ def included_component_ids
167
+ @included_component_ids ||= (@components || @organization.components).map(&:id).to_set
168
+ end
201
169
 
202
- properties
203
- end
170
+ def collapse(field, schema)
171
+ field.max_items == 1 ? schema : { type: "array", items: schema }
172
+ end
173
+
174
+ def json_content(schema)
175
+ { "application/json" => { schema: schema } }
176
+ end
177
+
178
+ # Distinct slugs can produce the same camelized name (top-10 and top10
179
+ # both yield Top10), so names are deduped once per generation.
180
+ def schema_name(content_type)
181
+ schema_names.fetch(content_type.id)
182
+ end
204
183
 
205
- def field_type_to_schema(field)
206
- case field.field_type
207
- when 'string', 'text'
208
- { "type" => "string" }
209
- when 'integer'
210
- { "type" => "integer" }
211
- when 'boolean'
212
- { "type" => "boolean" }
213
- when 'media'
214
- {
215
- "type" => "object",
216
- "properties" => {
217
- "url" => { "type" => "string" },
218
- "filename" => { "type" => "string" },
219
- "content_type" => { "type" => "string" }
220
- }
221
- }
222
- when 'relation'
223
- {
224
- "type" => "object",
225
- "properties" => {
226
- "id" => { "type" => "string" },
227
- "title" => { "type" => "string" }
228
- }
229
- }
230
- when 'component'
231
- {
232
- "type" => "object",
233
- "properties" => {
234
- "id" => { "type" => "string" },
235
- "type" => { "type" => "string" },
236
- "fields" => { "type" => "object" }
237
- }
238
- }
239
- else
240
- { "type" => "string" }
184
+ def schema_names
185
+ @schema_names ||= @organization.content_types.order(:id).each_with_object({}) do |ct, map|
186
+ base = ct.slug.split("-").map(&:capitalize).join
187
+ name = base
188
+ suffix = 2
189
+ while map.value?(name)
190
+ name = "#{base}_#{suffix}"
191
+ suffix += 1
241
192
  end
193
+ map[ct.id] = name
242
194
  end
243
195
  end
244
196
  end
245
- end
197
+ end
@@ -0,0 +1,64 @@
1
+ module RivetCms
2
+ # Enforces retention for one document: keeps the newest N superseded
3
+ # published snapshots per locale and destroys the rest. Candidates exclude
4
+ # the document's live pointers, re-read from the database so a stale
5
+ # in-memory copy cannot target a revision another request just published,
6
+ # and pruning is skipped entirely unless a live published revision exists.
7
+ class RevisionPruner
8
+ def initialize(document, keep_ids: [])
9
+ @document = document
10
+ @keep_ids = Array(keep_ids).compact
11
+ @destroyed = 0
12
+ end
13
+
14
+ def prune!
15
+ @destroyed = 0 # defensive: report this run, not a previous one
16
+ retention = RivetCms.normalized_retention_for(@document)
17
+ return 0 if retention == :all
18
+
19
+ published_id, draft_id = live_pointers
20
+ return 0 if published_id.nil? # nothing is live: never guess what to keep
21
+
22
+ protected_ids = ([ published_id, draft_id ] + @keep_ids).compact
23
+ live_locale = DocumentRevision.where(id: published_id).pick(:locale)
24
+
25
+ # Retention applies per locale so one locale's publishes cannot destroy
26
+ # another's, and no locale's snapshots become unreachable. A locale with
27
+ # no live pointer keeps its newest snapshot as that locale's live one.
28
+ locales.each do |locale|
29
+ allowance = locale == live_locale ? retention : retention + 1
30
+ doomed = candidates(protected_ids, locale).order(created_at: :desc, id: :desc).offset(allowance)
31
+ doomed.each do |revision|
32
+ Hooks.run(:prune, revision)
33
+ # destroy returns false when a callback halts it; count what actually
34
+ # went, so the count and log cannot overstate the work.
35
+ if revision.destroy
36
+ @destroyed += 1
37
+ else
38
+ Rails.logger&.warn("[RivetCms] could not prune revision #{revision.id}: destroy was halted")
39
+ end
40
+ end
41
+ end
42
+
43
+ Rails.logger&.info("[RivetCms] pruned #{@destroyed} superseded revision(s) for document #{@document.id}") if @destroyed.positive?
44
+ @destroyed
45
+ end
46
+
47
+ private
48
+
49
+ # Read live pointers from the database, not the attribute cache
50
+ def live_pointers
51
+ Document.where(id: @document.id).pick(:published_revision_id, :draft_revision_id) || [ nil, nil ]
52
+ end
53
+
54
+ def locales
55
+ @document.revisions.where(state: DocumentRevision.states[:published]).distinct.pluck(:locale)
56
+ end
57
+
58
+ def candidates(protected_ids, locale)
59
+ @document.revisions
60
+ .where(state: DocumentRevision.states[:published], locale: locale)
61
+ .where.not(id: protected_ids)
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,61 @@
1
+ module RivetCms
2
+ # Fans a lifecycle event out to the host-configured webhook endpoints.
3
+ # RivetCms.webhooks is an array of { url:, events: } hashes; events is
4
+ # optional and defaults to everything.
5
+ module Webhooks
6
+ EVENT_NAMES = { publish: "entry.published" }.freeze
7
+
8
+ def self.deliver(event, revision)
9
+ endpoints_for(event).each do |endpoint|
10
+ url = endpoint[:url] || endpoint["url"]
11
+ next if url.blank?
12
+
13
+ WebhookDeliveryJob.perform_later(url, payload(event, revision))
14
+ end
15
+ end
16
+
17
+ def self.endpoints_for(event)
18
+ name = EVENT_NAMES.fetch(event, event.to_s)
19
+ Array(RivetCms.webhooks).select do |endpoint|
20
+ events = endpoint[:events] || endpoint["events"]
21
+ events.nil? || events.map(&:to_s).include?(name)
22
+ end
23
+ end
24
+
25
+ def self.payload(event, revision)
26
+ document = revision.document
27
+ {
28
+ event: EVENT_NAMES.fetch(event),
29
+ document_id: document.prefix_id,
30
+ slug: document.slug,
31
+ # with_discarded: publishing into a removed type is reachable from host
32
+ # code, and the payload should still say which type it was.
33
+ content_type: ContentType.with_discarded.where(id: document.content_type_id).pick(:slug),
34
+ organization: document.organization.subdomain,
35
+ locale: revision.locale,
36
+ published_at: revision.published_at&.iso8601,
37
+ author: revision.author_name
38
+ }
39
+ end
40
+
41
+ # Fails fast at boot so a malformed entry cannot silently disable
42
+ # delivery (publish-path errors are swallowed by the hook seam).
43
+ def self.validate_config!
44
+ Array(RivetCms.webhooks).each do |endpoint|
45
+ raise ArgumentError, "RivetCms.webhooks entries must be hashes, got #{endpoint.class}" unless endpoint.is_a?(Hash)
46
+
47
+ url = endpoint[:url] || endpoint["url"]
48
+ uri = URI.parse(url.to_s) rescue nil
49
+ unless uri.is_a?(URI::HTTP) # covers HTTPS (subclass)
50
+ raise ArgumentError, "RivetCms.webhooks url must be http(s), got #{url.inspect}"
51
+ end
52
+
53
+ events = endpoint[:events] || endpoint["events"]
54
+ next if events.nil?
55
+ unless events.is_a?(Array) && (events.map(&:to_s) - EVENT_NAMES.values).empty?
56
+ raise ArgumentError, "RivetCms.webhooks events must be an array from #{EVENT_NAMES.values.inspect}, got #{events.inspect}"
57
+ end
58
+ end
59
+ end
60
+ end
61
+ end
@@ -1,49 +1,42 @@
1
1
  <!DOCTYPE html>
2
- <html class="h-full bg-gray-50">
3
- <head>
4
- <title>Rivet CMS</title>
5
- <meta name="viewport" content="width=device-width,initial-scale=1">
6
- <%= csrf_meta_tags %>
7
- <%= csp_meta_tag %>
2
+ <html data-theme="lofi">
3
+ <head>
4
+ <title><%= content_for(:title) || "Rivet CMS" %></title>
5
+ <meta name="viewport" content="width=device-width,initial-scale=1">
6
+ <meta name="apple-mobile-web-app-capable" content="yes">
7
+ <meta name="mobile-web-app-capable" content="yes">
8
+ <%= csrf_meta_tags %>
9
+ <%= csp_meta_tag %>
8
10
 
9
- <%= yield :head %>
11
+ <link rel="icon" href="/icon.png" type="image/png">
12
+ <link rel="icon" href="/icon.svg" type="image/svg+xml">
13
+ <link rel="apple-touch-icon" href="/icon.png">
10
14
 
11
- <%= javascript_include_tag "rivet_cms", "data-turbo-track": "reload", type: "module" %>
12
- <%= stylesheet_link_tag "rivet_cms", "data-turbo-track": "reload", time: Time.now.to_i %>
15
+ <link rel="preconnect" href="https://fonts.googleapis.com">
16
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
17
+ <link href="https://fonts.googleapis.com/css2?family=Archivo:ital,wght@0,400..800;1,400..800&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
13
18
 
14
- <link rel="preconnect" href="https://fonts.googleapis.com">
15
- <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
16
- <link href="https://fonts.googleapis.com/css2?family=Passion+One:wght@400;700;900&display=swap" rel="stylesheet">
17
- <%= brand_color_css.html_safe %>
18
- </head>
19
- <body class="h-full font-sans antialiased text-gray-900">
20
- <div class="min-h-full">
21
- <!-- Top navigation -->
22
- <%= render "rivet_cms/shared/navigation" %>
19
+ <script>
20
+ // Apply persisted theme before first paint to avoid a flash
21
+ try {
22
+ var stored = localStorage.getItem("rivetTheme");
23
+ if (stored ? stored === "dark" : matchMedia("(prefers-color-scheme: dark)").matches) {
24
+ document.documentElement.setAttribute("data-theme", "dark");
25
+ }
26
+ } catch (_) {}
27
+ </script>
23
28
 
24
- <!-- Flash messages -->
25
- <% if flash.any? %>
26
- <div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 mt-4">
27
- <% flash.each do |type, message| %>
28
- <div class="<%= flash_class_for(type) %> p-4 rounded-md mb-4">
29
- <div class="flex">
30
- <div class="flex-shrink-0">
31
- <%= flash_icon_for(type) %>
32
- </div>
33
- <div class="ml-3">
34
- <p class="text-sm font-medium"><%= message %></p>
35
- </div>
36
- </div>
37
- </div>
38
- <% end %>
39
- </div>
29
+ <%= stylesheet_link_tag "rivet_cms", media: "all" %>
30
+ <% RivetCms.admin_stylesheets.each do |name| %>
31
+ <%= admin_extension_tag :stylesheet, name %>
40
32
  <% end %>
41
-
42
- <main>
43
- <div class="mx-auto max-w-7xl py-6 sm:px-6 lg:px-8">
44
- <%= yield %>
45
- </div>
46
- </main>
47
- </div>
48
- </body>
33
+ <%# Extension bundles come after core so window.RivetCMS exists when they run %>
34
+ <%= javascript_include_tag "rivet_cms", type: "module", defer: true %>
35
+ <% RivetCms.admin_scripts.each do |name| %>
36
+ <%= admin_extension_tag :script, name %>
37
+ <% end %>
38
+ </head>
39
+ <body>
40
+ <%= yield %>
41
+ </body>
49
42
  </html>