iron-cms 0.17.2 → 0.18.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 (142) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +88 -4
  3. data/app/assets/builds/iron.css +255 -106
  4. data/app/assets/tailwind/iron/application.css +1 -0
  5. data/app/assets/tailwind/iron/components/file-upload.css +26 -0
  6. data/app/assets/tailwind/iron/lexxy.css +111 -87
  7. data/app/controllers/concerns/iron/schema_editing.rb +19 -0
  8. data/app/controllers/iron/api/schema/base_controller.rb +25 -0
  9. data/app/controllers/iron/api/schema/block_definitions_controller.rb +49 -0
  10. data/app/controllers/iron/api/schema/content_types_controller.rb +64 -0
  11. data/app/controllers/iron/api/schema/field_definitions_controller.rb +88 -0
  12. data/app/controllers/iron/api/schema/locales_controller.rb +52 -0
  13. data/app/controllers/iron/application_controller.rb +1 -1
  14. data/app/controllers/iron/block_definitions_controller.rb +1 -0
  15. data/app/controllers/iron/content_types_controller.rb +1 -0
  16. data/app/controllers/iron/field_definitions_controller.rb +2 -1
  17. data/app/controllers/iron/first_runs_controller.rb +1 -1
  18. data/app/controllers/iron/locales_controller.rb +1 -0
  19. data/app/controllers/iron/sessions_controller.rb +1 -1
  20. data/app/helpers/iron/avatar_helper.rb +24 -2
  21. data/app/javascript/iron/controllers/file_upload_controller.js +38 -7
  22. data/app/models/iron/account.rb +1 -1
  23. data/app/models/iron/api/openapi_spec.rb +37 -15
  24. data/app/models/iron/block_definition/exportable.rb +1 -1
  25. data/app/models/iron/block_definition.rb +3 -1
  26. data/app/models/iron/content.rb +176 -0
  27. data/app/models/iron/content_type/exportable.rb +3 -1
  28. data/app/models/iron/content_type/importable.rb +1 -1
  29. data/app/models/iron/content_type.rb +6 -1
  30. data/app/models/iron/entry/content_assignable.rb +10 -2
  31. data/app/models/iron/exporter.rb +1 -26
  32. data/app/models/iron/field/length_constrained.rb +17 -0
  33. data/app/models/iron/field/validatable.rb +16 -0
  34. data/app/models/iron/field.rb +1 -1
  35. data/app/models/iron/field_definition/exportable.rb +3 -4
  36. data/app/models/iron/field_definition/importable.rb +16 -9
  37. data/app/models/iron/field_definition/ranked.rb +1 -1
  38. data/app/models/iron/field_definition/searchable.rb +2 -0
  39. data/app/models/iron/field_definition/validatable.rb +40 -0
  40. data/app/models/iron/field_definition/validations.rb +175 -0
  41. data/app/models/iron/field_definition.rb +1 -1
  42. data/app/models/iron/field_definitions/date.rb +2 -0
  43. data/app/models/iron/field_definitions/file.rb +3 -11
  44. data/app/models/iron/field_definitions/number.rb +2 -0
  45. data/app/models/iron/field_definitions/reference.rb +2 -0
  46. data/app/models/iron/field_definitions/rich_text_area.rb +1 -0
  47. data/app/models/iron/field_definitions/text_area.rb +2 -0
  48. data/app/models/iron/field_definitions/text_field.rb +1 -9
  49. data/app/models/iron/fields/block.rb +5 -1
  50. data/app/models/iron/fields/block_list.rb +5 -1
  51. data/app/models/iron/fields/date.rb +19 -2
  52. data/app/models/iron/fields/file.rb +5 -3
  53. data/app/models/iron/fields/number.rb +28 -1
  54. data/app/models/iron/fields/reference.rb +2 -0
  55. data/app/models/iron/fields/rich_text_area.rb +2 -0
  56. data/app/models/iron/fields/text_area.rb +4 -0
  57. data/app/models/iron/fields/text_field.rb +9 -5
  58. data/app/models/iron/importer.rb +1 -54
  59. data/app/models/iron/locale.rb +2 -0
  60. data/app/models/iron/schema/auto_dumpable.rb +26 -0
  61. data/app/models/iron/schema/diff.rb +194 -0
  62. data/app/models/iron/schema/validation.rb +214 -0
  63. data/app/models/iron/schema.rb +282 -0
  64. data/app/models/iron/seed.rb +5 -3
  65. data/app/models/iron/system.rb +7 -0
  66. data/app/models/iron/user/deactivatable.rb +5 -0
  67. data/app/models/iron/user.rb +18 -1
  68. data/app/views/iron/api/fields/_rich_text_area.json.jbuilder +2 -2
  69. data/app/views/iron/api/schema/block_definitions/_block_definition.json.jbuilder +4 -0
  70. data/app/views/iron/api/schema/block_definitions/index.json.jbuilder +1 -0
  71. data/app/views/iron/api/schema/block_definitions/show.json.jbuilder +1 -0
  72. data/app/views/iron/api/schema/content_types/_content_type.json.jbuilder +5 -0
  73. data/app/views/iron/api/schema/content_types/index.json.jbuilder +1 -0
  74. data/app/views/iron/api/schema/content_types/show.json.jbuilder +1 -0
  75. data/app/views/iron/api/schema/field_definitions/_field_definition.json.jbuilder +7 -0
  76. data/app/views/iron/api/schema/field_definitions/show.json.jbuilder +1 -0
  77. data/app/views/iron/api/schema/locales/_locale.json.jbuilder +4 -0
  78. data/app/views/iron/api/schema/locales/index.json.jbuilder +1 -0
  79. data/app/views/iron/api/schema/locales/show.json.jbuilder +1 -0
  80. data/app/views/iron/block_definitions/_empty_state.html.erb +5 -3
  81. data/app/views/iron/block_definitions/_form.html.erb +3 -1
  82. data/app/views/iron/block_definitions/edit.html.erb +19 -17
  83. data/app/views/iron/block_definitions/index.html.erb +7 -3
  84. data/app/views/iron/block_definitions/show.html.erb +14 -8
  85. data/app/views/iron/content_types/_content_type.html.erb +1 -1
  86. data/app/views/iron/content_types/_empty_state.html.erb +5 -3
  87. data/app/views/iron/content_types/_form.html.erb +12 -8
  88. data/app/views/iron/content_types/edit.html.erb +19 -17
  89. data/app/views/iron/content_types/index.html.erb +7 -3
  90. data/app/views/iron/content_types/show.html.erb +14 -8
  91. data/app/views/iron/entries/_empty_state.html.erb +1 -1
  92. data/app/views/iron/entries/edit.html.erb +1 -1
  93. data/app/views/iron/entries/entry.html.erb +1 -1
  94. data/app/views/iron/entries/fields/_block.html.erb +4 -0
  95. data/app/views/iron/entries/fields/_block_list.html.erb +2 -0
  96. data/app/views/iron/entries/fields/_boolean.html.erb +1 -0
  97. data/app/views/iron/entries/fields/_date.html.erb +3 -2
  98. data/app/views/iron/entries/fields/_field_errors.html.erb +7 -0
  99. data/app/views/iron/entries/fields/_field_label.html.erb +8 -0
  100. data/app/views/iron/entries/fields/_file.html.erb +11 -19
  101. data/app/views/iron/entries/fields/_number.html.erb +6 -2
  102. data/app/views/iron/entries/fields/_reference.html.erb +2 -1
  103. data/app/views/iron/entries/fields/_reference_list.html.erb +2 -0
  104. data/app/views/iron/entries/fields/_rich_text_area.html.erb +2 -1
  105. data/app/views/iron/entries/fields/_text_area.html.erb +6 -2
  106. data/app/views/iron/entries/fields/_text_field.html.erb +5 -12
  107. data/app/views/iron/field_definitions/_field_definition.html.erb +51 -29
  108. data/app/views/iron/field_definitions/date/_form.html.erb +5 -1
  109. data/app/views/iron/field_definitions/edit.html.erb +10 -8
  110. data/app/views/iron/field_definitions/file/_form.html.erb +6 -0
  111. data/app/views/iron/field_definitions/new.html.erb +5 -3
  112. data/app/views/iron/field_definitions/number/_form.html.erb +23 -1
  113. data/app/views/iron/field_definitions/reference/_form.html.erb +5 -0
  114. data/app/views/iron/field_definitions/rich_text_area/_form.html.erb +5 -0
  115. data/app/views/iron/field_definitions/text_area/_form.html.erb +23 -0
  116. data/app/views/iron/field_definitions/text_field/_form.html.erb +20 -1
  117. data/app/views/iron/home/_content_types.html.erb +1 -1
  118. data/app/views/iron/locales/_form.html.erb +5 -3
  119. data/app/views/iron/locales/edit.html.erb +1 -1
  120. data/app/views/iron/locales/index.html.erb +12 -6
  121. data/app/views/iron/shared/_schema_lock_badge.html.erb +19 -0
  122. data/config/locales/en.yml +13 -1
  123. data/config/locales/it.yml +18 -1
  124. data/config/routes.rb +11 -0
  125. data/db/migrate/20260612131538_create_iron_systems.rb +9 -0
  126. data/exe/iron +5 -0
  127. data/lib/generators/iron/agents/agents_generator.rb +52 -0
  128. data/lib/generators/iron/agents/templates/AGENTS.md +24 -0
  129. data/lib/generators/iron/agents/templates/SKILL.md +423 -0
  130. data/lib/generators/iron/install/install_generator.rb +118 -0
  131. data/lib/generators/iron/install/templates/iron_release.rake +5 -0
  132. data/lib/generators/iron/install/templates/schema.json +12 -0
  133. data/lib/generators/iron/install/templates/seeds.rb +13 -0
  134. data/lib/generators/iron/pages/pages_generator.rb +5 -0
  135. data/lib/generators/iron/pages/templates/pages_controller.rb +1 -1
  136. data/lib/generators/iron/pages/templates/show.html.erb +1 -1
  137. data/lib/install/template.rb +9 -0
  138. data/lib/iron/cli.rb +43 -0
  139. data/lib/iron/version.rb +1 -1
  140. data/lib/tasks/iron_content.rake +82 -0
  141. data/lib/tasks/iron_schema.rake +45 -0
  142. metadata +62 -3
@@ -1,5 +1,7 @@
1
1
  module Iron
2
2
  module AvatarHelper
3
+ SYSTEM_AVATAR_CLASSES = "bg-sky-100 text-sky-600 dark:bg-sky-500/20 dark:text-sky-400"
4
+
3
5
  def avatar(user, options = {})
4
6
  variant = options.delete(:variant) || "circle"
5
7
  alt = options.delete(:alt) || ""
@@ -9,10 +11,31 @@ module Iron
9
11
  "inline-grid shrink-0 align-middle [--avatar-radius:20%] [--ring-opacity:20%] *:col-start-1 *:row-start-1",
10
12
  "outline outline-1 -outline-offset-1 outline-black/(--ring-opacity) dark:outline-white/(--ring-opacity)",
11
13
  # Add the correct border radius
12
- variant == "square" ? "rounded-(--avatar-radius) *:rounded-(--avatar-radius)" : "rounded-full *:rounded-full"
14
+ variant == "square" ? "rounded-(--avatar-radius) *:rounded-(--avatar-radius)" : "rounded-full *:rounded-full",
15
+ # The system user wears the accent treatment instead of the caller's palette
16
+ user.iron_system? ? SYSTEM_AVATAR_CLASSES : nil
13
17
  )
14
18
 
15
19
  tag.span class: css_class, data: { slot: "avatar" } do
20
+ user.iron_system? ? system_glyph : initials_glyph(user, alt)
21
+ end
22
+ end
23
+
24
+ def user_icon(user, **options)
25
+ user.iron_system? ? system_icon(**options) : icon("circle-user", **options)
26
+ end
27
+
28
+ def system_icon(**options)
29
+ icon "bot", **options
30
+ end
31
+
32
+ private
33
+
34
+ def system_glyph
35
+ system_icon class: "size-full p-[22%]"
36
+ end
37
+
38
+ def initials_glyph(user, alt)
16
39
  tag.svg class: "size-full select-none fill-current p-[5%] text-[48px] font-medium uppercase",
17
40
  viewbox: "0 0 100 100",
18
41
  "aria-hidden": alt.present? ? nil : true do
@@ -30,6 +53,5 @@ module Iron
30
53
  ])
31
54
  end
32
55
  end
33
- end
34
56
  end
35
57
  end
@@ -1,7 +1,7 @@
1
1
  import { Controller } from "@hotwired/stimulus";
2
2
 
3
3
  export default class FileUploadController extends Controller {
4
- static targets = ["preview", "placeholder", "previewWrapper"];
4
+ static targets = ["preview", "placeholder", "previewWrapper", "filename"];
5
5
 
6
6
  connect() {
7
7
  if (this.hasPreviewTarget) {
@@ -16,19 +16,27 @@ export default class FileUploadController extends Controller {
16
16
  const file = event.target.files[0];
17
17
  if (!file) return;
18
18
 
19
- if (this.hasPreviewTarget) {
20
- this.previewTarget.src = URL.createObjectURL(file);
21
- }
22
- if (this.hasPlaceholderTarget) {
23
- this.placeholderTarget.classList.add("hidden");
19
+ const kind = this.#kindOf(file.type);
20
+
21
+ if (kind === "image") {
22
+ if (this.hasPreviewTarget) {
23
+ this.previewTarget.src = URL.createObjectURL(file);
24
+ }
25
+ } else if (this.hasFilenameTarget) {
26
+ this.filenameTarget.textContent = file.name;
24
27
  }
25
- this.previewElement?.classList.remove("hidden");
28
+
29
+ this.#markFileKind(kind);
30
+ this.#revealPreview();
26
31
  }
27
32
 
28
33
  reset() {
29
34
  if (this.hasPreviewTarget) {
30
35
  this.previewTarget.src = this.originalSrc;
31
36
  }
37
+ if (this.hasPreviewWrapperTarget) {
38
+ delete this.previewWrapperTarget.dataset.fileKind;
39
+ }
32
40
  if (this.originalHidden) {
33
41
  this.previewElement?.classList.add("hidden");
34
42
  if (this.hasPlaceholderTarget) {
@@ -42,4 +50,27 @@ export default class FileUploadController extends Controller {
42
50
  if (this.hasPreviewTarget) return this.previewTarget;
43
51
  return null;
44
52
  }
53
+
54
+ // Private
55
+
56
+ #markFileKind(kind) {
57
+ if (this.hasPreviewWrapperTarget) {
58
+ this.previewWrapperTarget.dataset.fileKind = kind;
59
+ }
60
+ }
61
+
62
+ #revealPreview() {
63
+ if (this.hasPlaceholderTarget) {
64
+ this.placeholderTarget.classList.add("hidden");
65
+ }
66
+ this.previewElement?.classList.remove("hidden");
67
+ }
68
+
69
+ #kindOf(contentType) {
70
+ if (contentType.startsWith("image/")) return "image";
71
+ if (contentType === "application/pdf") return "pdf";
72
+ if (contentType.startsWith("audio/")) return "audio";
73
+ if (contentType.startsWith("video/")) return "video";
74
+ return "file";
75
+ }
45
76
  }
@@ -1,6 +1,6 @@
1
1
  module Iron
2
2
  class Account < ApplicationRecord
3
- include Joinable
3
+ include Joinable, Schema::AutoDumpable
4
4
 
5
5
  has_secure_token :instance_token
6
6
 
@@ -208,7 +208,7 @@ module Iron
208
208
  },
209
209
  ValidationErrors: {
210
210
  type: "object",
211
- description: "Field handle to error messages mapping",
211
+ description: "Field path to error messages mapping. Paths may be nested, e.g. `sections[0].title`.",
212
212
  additionalProperties: { type: "array", items: { type: "string" } }
213
213
  },
214
214
  FileObject: {
@@ -275,7 +275,7 @@ module Iron
275
275
 
276
276
  ct.field_definitions.each do |fd|
277
277
  content_properties[fd.handle.to_sym] = field_write_schema(fd)
278
- required_fields << fd.handle if fd.respond_to?(:required) && fd.required
278
+ required_fields << fd.handle if fd.required?
279
279
  end
280
280
 
281
281
  content_schema = { type: "object", properties: content_properties }
@@ -307,7 +307,7 @@ module Iron
307
307
  properties[fd.handle.to_sym] = field_write_schema(fd)
308
308
  end
309
309
 
310
- { type: "object", properties: properties, required: [ "_type" ] }
310
+ { type: "object", properties: properties, required: [ "_type" ] + bd.field_definitions.select(&:required?).map(&:handle) }
311
311
  end
312
312
 
313
313
  # --- Field type mapping ---
@@ -315,12 +315,12 @@ module Iron
315
315
  def field_read_schema(fd)
316
316
  case fd
317
317
  when FieldDefinitions::TextField
318
- schema = { type: [ "string", "null" ] }
319
- schema[:enum] = fd.allowed_values + [ nil ] if fd.respond_to?(:allowed_values) && fd.allowed_values.present?
318
+ schema = { type: [ "string", "null" ] }.merge(length_bounds(fd))
319
+ schema[:enum] = fd.allowed_values + [ nil ] if fd.allowed_values.present?
320
320
  schema
321
- when FieldDefinitions::TextArea then { type: [ "string", "null" ] }
321
+ when FieldDefinitions::TextArea then { type: [ "string", "null" ] }.merge(length_bounds(fd))
322
322
  when FieldDefinitions::RichTextArea then { oneOf: [ { "$ref": "#/components/schemas/RichText" }, { type: "null" } ] }
323
- when FieldDefinitions::Number then { type: [ "number", "null" ] }
323
+ when FieldDefinitions::Number then { type: [ "number", "null" ] }.merge(number_bounds(fd))
324
324
  when FieldDefinitions::Boolean then { type: [ "boolean", "null" ] }
325
325
  when FieldDefinitions::Date then { type: [ "string", "null" ], format: "date-time" }
326
326
  when FieldDefinitions::File then { oneOf: [ { "$ref": "#/components/schemas/FileObject" }, { type: "null" } ] }
@@ -338,16 +338,16 @@ module Iron
338
338
  def field_write_schema(fd)
339
339
  case fd
340
340
  when FieldDefinitions::TextField
341
- schema = { type: [ "string", "null" ] }
342
- schema[:enum] = fd.allowed_values + [ nil ] if fd.respond_to?(:allowed_values) && fd.allowed_values.present?
341
+ schema = { type: write_type(fd, "string") }.merge(length_bounds(fd))
342
+ schema[:enum] = fd.required? ? fd.allowed_values : fd.allowed_values + [ nil ] if fd.allowed_values.present?
343
343
  schema
344
- when FieldDefinitions::TextArea then { type: [ "string", "null" ] }
345
- when FieldDefinitions::RichTextArea then { type: [ "string", "null" ], description: "HTML content" }
346
- when FieldDefinitions::Number then { type: [ "number", "null" ] }
344
+ when FieldDefinitions::TextArea then { type: write_type(fd, "string") }.merge(length_bounds(fd))
345
+ when FieldDefinitions::RichTextArea then { type: write_type(fd, "string"), description: "HTML content" }
346
+ when FieldDefinitions::Number then { type: write_type(fd, "number") }.merge(number_bounds(fd))
347
347
  when FieldDefinitions::Boolean then { type: [ "boolean", "null" ] }
348
- when FieldDefinitions::Date then { type: [ "string", "null" ], format: "date-time" }
349
- when FieldDefinitions::File then { oneOf: [ { type: "string", description: "Upload token (signed_id)" }, { type: "null" } ] }
350
- when FieldDefinitions::Reference then { oneOf: [ { type: "integer", description: "Entry ID" }, { type: "null" } ] }
348
+ when FieldDefinitions::Date then { type: write_type(fd, "string"), format: "date-time" }
349
+ when FieldDefinitions::File then nullable_unless_required(fd, type: "string", description: upload_token_description(fd))
350
+ when FieldDefinitions::Reference then nullable_unless_required(fd, type: "integer", description: "Entry ID")
351
351
  when FieldDefinitions::ReferenceList then { type: "array", items: { type: "integer", description: "Entry ID" } }
352
352
  when FieldDefinitions::Block
353
353
  bd = fd.supported_block_definition
@@ -358,6 +358,28 @@ module Iron
358
358
  end
359
359
  end
360
360
 
361
+ def write_type(fd, type)
362
+ fd.required? ? type : [ type, "null" ]
363
+ end
364
+
365
+ def nullable_unless_required(fd, schema)
366
+ fd.required? ? schema : { oneOf: [ schema, { type: "null" } ] }
367
+ end
368
+
369
+ def length_bounds(fd)
370
+ { minLength: fd.min_length, maxLength: fd.max_length }.compact
371
+ end
372
+
373
+ def number_bounds(fd)
374
+ { minimum: fd.min, maximum: fd.max }.compact
375
+ end
376
+
377
+ def upload_token_description(fd)
378
+ return "Upload token (signed_id)" if fd.accepted_extensions.blank?
379
+
380
+ "Upload token (signed_id). Accepted file types: #{fd.accepted_extensions.join(', ')}"
381
+ end
382
+
361
383
  # --- Helpers ---
362
384
 
363
385
  def content_types
@@ -8,7 +8,7 @@ module Iron
8
8
  name:,
9
9
  description:,
10
10
  field_definitions: field_definitions.ranked.map(&:export_attributes)
11
- }
11
+ }.compact_blank
12
12
  end
13
13
  end
14
14
  end
@@ -1,6 +1,6 @@
1
1
  module Iron
2
2
  class BlockDefinition < ApplicationRecord
3
- include Exportable, Importable
3
+ include Exportable, Importable, Schema::AutoDumpable
4
4
 
5
5
  has_many :field_definitions, -> { ranked }, as: :schemable, dependent: :destroy
6
6
 
@@ -14,5 +14,7 @@ module Iron
14
14
 
15
15
  validates :name, presence: true
16
16
  validates :handle, presence: true
17
+
18
+ def required? = false
17
19
  end
18
20
  end
@@ -0,0 +1,176 @@
1
+ require "open-uri"
2
+
3
+ module Iron
4
+ class Content
5
+ class Error < StandardError; end
6
+
7
+ class InvalidContent < Error
8
+ attr_reader :errors
9
+
10
+ def initialize(errors)
11
+ @errors = errors
12
+ super(errors.map { |path, messages| "#{path} #{Array(messages).join(", ")}" }.join("; "))
13
+ end
14
+ end
15
+
16
+ class << self
17
+ def list(handle, locale: nil)
18
+ as_system(locale) do
19
+ content_type(handle).entries.order(:id).map { |entry| render_entry(entry) }
20
+ end
21
+ end
22
+
23
+ def get(handle, id: nil, route: nil, locale: nil)
24
+ as_system(locale) do
25
+ render_entry(locate_entry(content_type(handle), id:, route:))
26
+ end
27
+ end
28
+
29
+ def create(handle, content, route: nil, locale: nil)
30
+ type = content_type(handle)
31
+ raise Error, "#{handle} is a single — use update" if type.single?
32
+
33
+ as_system(locale) do
34
+ entry = type.entries.build
35
+ entry.route = route if route
36
+ write_entry(entry, content)
37
+ end
38
+ end
39
+
40
+ def update(handle, content, id: nil, route: nil, locale: nil)
41
+ type = content_type(handle)
42
+
43
+ as_system(locale) do
44
+ write_entry(entry_for_update(type, id:, route:), content)
45
+ end
46
+ end
47
+
48
+ def delete(handle, id: nil, route: nil)
49
+ as_system(nil) do
50
+ entry = locate_entry(content_type(handle), id:, route:)
51
+ purge_file_attachments(entry)
52
+ entry.destroy!
53
+ { "deleted" => true, "id" => entry.id }
54
+ end
55
+ end
56
+
57
+ private
58
+
59
+ def as_system(locale_code, &block)
60
+ Current.set(user: User.system, locale: resolve_locale(locale_code)) do
61
+ ActiveStorage::Current.set(url_options: storage_url_options, &block)
62
+ end
63
+ end
64
+
65
+ def resolve_locale(code)
66
+ code.present? ? Locale.find_by!(code:) : Locale.default
67
+ end
68
+
69
+ def storage_url_options
70
+ Rails.application.routes.default_url_options.presence || { host: "localhost", port: 3000 }
71
+ end
72
+
73
+ def content_type(handle)
74
+ ContentType.find_by!(handle:)
75
+ end
76
+
77
+ def locate_entry(content_type, id:, route:)
78
+ if id
79
+ content_type.entries.find(id)
80
+ elsif route
81
+ content_type.entries.find_by!(route:)
82
+ else
83
+ singleton_entry(content_type)
84
+ end
85
+ end
86
+
87
+ def singleton_entry(content_type)
88
+ raise Error, "#{content_type.handle} is a collection — pass an id or route" unless content_type.single?
89
+
90
+ content_type.entry.tap do |entry|
91
+ raise ActiveRecord::RecordNotFound, "#{content_type.handle} has no entry yet" unless entry.persisted?
92
+ end
93
+ end
94
+
95
+ def entry_for_update(content_type, id:, route:)
96
+ if id.nil? && route.nil? && content_type.single?
97
+ content_type.entry
98
+ else
99
+ locate_entry(content_type, id:, route:)
100
+ end
101
+ end
102
+
103
+ def write_entry(entry, content)
104
+ content ||= {}
105
+ raise Error, "payload must be a JSON object of field handles" unless content.is_a?(Hash)
106
+
107
+ entry.assign_content(ingest_files(content))
108
+ raise InvalidContent, entry.content_errors unless entry.save
109
+
110
+ render_entry(entry)
111
+ end
112
+
113
+ def purge_file_attachments(entry)
114
+ entry.fields.each do |field|
115
+ field.file.purge if field.is_a?(Fields::File) && field.file.attached?
116
+ end
117
+ end
118
+
119
+ def render_entry(entry)
120
+ JSON.parse(Api::BaseController.render(partial: "iron/api/entry", formats: [ :json ], locals: { entry: }))
121
+ end
122
+
123
+ def ingest_files(content)
124
+ case content
125
+ when Hash
126
+ if file_directive?(content)
127
+ uploaded_blob(file_source(content)).signed_id
128
+ else
129
+ content.transform_values { |value| ingest_files(value) }
130
+ end
131
+ when Array
132
+ content.map { |value| ingest_files(value) }
133
+ else
134
+ content
135
+ end
136
+ end
137
+
138
+ def file_directive?(hash)
139
+ hash.key?("_file") || hash.key?(:_file)
140
+ end
141
+
142
+ def file_source(hash)
143
+ hash["_file"] || hash[:_file]
144
+ end
145
+
146
+ def uploaded_blob(source)
147
+ unless source.is_a?(String) && source.present?
148
+ raise Error, '"_file" must be a file path or URL string'
149
+ end
150
+
151
+ source.match?(%r{\Ahttps?://}i) ? downloaded_blob(source) : local_blob(source)
152
+ end
153
+
154
+ def downloaded_blob(url)
155
+ URI.open(url) do |io|
156
+ ActiveStorage::Blob.create_and_upload!(io:, filename: File.basename(URI.parse(url).path))
157
+ end
158
+ rescue OpenURI::HTTPError, SocketError, URI::InvalidURIError, Timeout::Error, Errno::ECONNREFUSED => error
159
+ raise Error, "could not download #{url}: #{error.message}"
160
+ end
161
+
162
+ def local_blob(path)
163
+ file = Pathname.new(path)
164
+ file = Rails.root.join(file) if file.relative?
165
+
166
+ file.open do |io|
167
+ ActiveStorage::Blob.create_and_upload!(io:, filename: file.basename.to_s)
168
+ end
169
+ rescue Errno::ENOENT
170
+ raise Error, "file not found: #{path} (paths are relative to the app root)"
171
+ rescue Errno::EACCES, Errno::EISDIR => error
172
+ raise Error, "could not read file #{path}: #{error.message}"
173
+ end
174
+ end
175
+ end
176
+ end
@@ -2,6 +2,8 @@ module Iron
2
2
  module ContentType::Exportable
3
3
  extend ActiveSupport::Concern
4
4
 
5
+ # Blank attributes and false flags are omitted: schema files stay canonical
6
+ # and hand-written files don't churn under the development auto-dump.
5
7
  def export_attributes
6
8
  {
7
9
  handle:,
@@ -14,7 +16,7 @@ module Iron
14
16
  title_field_handle: title_field_definition&.handle,
15
17
  web_page_title_field_handle: web_page_title_field_definition&.handle,
16
18
  field_definitions: field_definitions.ranked.map(&:export_attributes)
17
- }
19
+ }.compact_blank
18
20
  end
19
21
  end
20
22
  end
@@ -11,7 +11,7 @@ module Iron
11
11
  name: attrs[:name],
12
12
  description: attrs[:description],
13
13
  icon: attrs[:icon],
14
- web_publishing_enabled: attrs[:web_publishing_enabled],
14
+ web_publishing_enabled: !!attrs[:web_publishing_enabled],
15
15
  base_path: attrs[:base_path]
16
16
  )
17
17
 
@@ -1,8 +1,9 @@
1
1
  module Iron
2
2
  class ContentType < ApplicationRecord
3
- include Titlable, WebPublishable, Exportable, Importable
3
+ include Titlable, WebPublishable, Exportable, Importable, Schema::AutoDumpable
4
4
 
5
5
  TYPES = %w[Single Collection].freeze
6
+ DEFAULT_ICON = "file-text"
6
7
 
7
8
  validates :name, presence: true
8
9
  validates :handle, presence: true
@@ -36,6 +37,10 @@ module Iron
36
37
  type.demodulize
37
38
  end
38
39
 
40
+ def display_icon
41
+ icon.presence.then { |name| IconCatalog.include?(name) ? name : nil } || DEFAULT_ICON
42
+ end
43
+
39
44
  def single?
40
45
  is_a?(ContentTypes::Single)
41
46
  end
@@ -5,7 +5,11 @@ module Iron
5
5
  def assign_content(content)
6
6
  content_type.field_definitions.each do |definition|
7
7
  raw_value = Field.content_fetch(content, definition.handle)
8
- next if raw_value == Field::CONTENT_MISSING
8
+
9
+ if raw_value == Field::CONTENT_MISSING
10
+ find_or_build_field(definition, Current.locale) if definition.required?
11
+ next
12
+ end
9
13
 
10
14
  field = find_or_build_field(definition, Current.locale)
11
15
  field.content_value = raw_value
@@ -17,7 +21,11 @@ module Iron
17
21
 
18
22
  errors.each do |error|
19
23
  next if error.type == :fields_invalid
20
- (details[error.attribute.to_s] ||= []) << error.message
24
+
25
+ attribute = error.attribute.to_s
26
+ next if attribute == "fields" || attribute.start_with?("fields.")
27
+
28
+ (details[attribute] ||= []) << error.message
21
29
  end
22
30
 
23
31
  collect_field_error_details(fields.select { |f| f.parent.nil? }, nil, details)
@@ -40,32 +40,7 @@ module Iron
40
40
  end
41
41
 
42
42
  def schema_json
43
- JSON.pretty_generate(
44
- version: "1.0",
45
- exported_at: Time.current.iso8601,
46
- default_locale: Current.account&.default_locale&.code,
47
- locales: export_locales,
48
- block_definitions: export_block_definitions,
49
- content_types: export_content_types
50
- )
51
- end
52
-
53
- def export_locales
54
- Locale.order(:code).map { |l| { code: l.code, name: l.name } }
55
- end
56
-
57
- def export_block_definitions
58
- BlockDefinition
59
- .includes(:field_definitions)
60
- .order(:handle)
61
- .map(&:export_attributes)
62
- end
63
-
64
- def export_content_types
65
- ContentType
66
- .includes(:title_field_definition, :web_page_title_field_definition, :field_definitions)
67
- .order(:handle)
68
- .map(&:export_attributes)
43
+ Schema.to_json
69
44
  end
70
45
 
71
46
  def exportable_entries
@@ -0,0 +1,17 @@
1
+ module Iron
2
+ module Field::LengthConstrained
3
+ extend ActiveSupport::Concern
4
+
5
+ included do
6
+ validate :value_within_length_bounds
7
+ end
8
+
9
+ private
10
+ def value_within_length_bounds
11
+ return if value.blank?
12
+
13
+ errors.add(:base, :too_short, count: definition.min_length) if definition.min_length && value.length < definition.min_length
14
+ errors.add(:base, :too_long, count: definition.max_length) if definition.max_length && value.length > definition.max_length
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,16 @@
1
+ module Iron
2
+ module Field::Validatable
3
+ extend ActiveSupport::Concern
4
+
5
+ included do
6
+ validate :enforce_required
7
+ end
8
+
9
+ def filled? = true
10
+
11
+ private
12
+ def enforce_required
13
+ errors.add(:base, :blank) if definition&.required? && !filled?
14
+ end
15
+ end
16
+ end
@@ -1,6 +1,6 @@
1
1
  module Iron
2
2
  class Field < ApplicationRecord
3
- include BelongsToEntry
3
+ include BelongsToEntry, Validatable
4
4
 
5
5
  TYPES = %w[text_field text_area rich_text_area number file boolean date block block_list reference_list reference].freeze
6
6
  CONTENT_MISSING = Object.new.freeze
@@ -7,15 +7,14 @@ module Iron
7
7
  handle:,
8
8
  name:,
9
9
  type: type_handle,
10
- rank:,
11
- metadata:
10
+ metadata: metadata.compact_blank
12
11
  }.tap do |attrs|
13
12
  if respond_to?(:supported_block_definitions)
14
- attrs[:supported_block_definitions] = supported_block_definitions.pluck(:handle)
13
+ attrs[:supported_block_definitions] = supported_block_definitions.pluck(:handle).sort
15
14
  end
16
15
 
17
16
  if respond_to?(:supported_content_types)
18
- attrs[:supported_content_types] = supported_content_types.pluck(:handle)
17
+ attrs[:supported_content_types] = supported_content_types.pluck(:handle).sort
19
18
  end
20
19
  end.compact_blank
21
20
  end
@@ -7,11 +7,11 @@ module Iron
7
7
  raise ArgumentError, "Field type is required" unless attrs[:type].present?
8
8
 
9
9
  field_def = schemable.field_definitions.find_or_initialize_by(handle: attrs[:handle])
10
+ discard_stored_values(field_def, attrs[:type])
10
11
  field_def = field_def.becomes!(classify_type(attrs[:type]).constantize)
11
12
 
12
13
  field_def.assign_attributes(
13
14
  name: attrs[:name],
14
- rank: attrs[:rank],
15
15
  metadata: attrs[:metadata] || {}
16
16
  )
17
17
 
@@ -21,19 +21,26 @@ module Iron
21
21
  field_def
22
22
  end
23
23
 
24
- private
25
-
24
+ # Omitted lists clear the supported definitions: the file is authoritative.
25
+ # Handles declared later in the same file resolve in Schema.import's second
26
+ # pass, once every block definition and content type exists.
26
27
  def assign_supported_definitions(field_def, attrs)
27
- if field_def.respond_to?(:supported_block_definition_ids=) && attrs[:supported_block_definitions].present?
28
- block_defs = BlockDefinition.where(handle: attrs[:supported_block_definitions])
29
- field_def.supported_block_definition_ids = block_defs.pluck(:id)
28
+ if field_def.respond_to?(:supported_block_definition_ids=)
29
+ field_def.supported_block_definition_ids = BlockDefinition.where(handle: attrs[:supported_block_definitions] || []).pluck(:id)
30
30
  end
31
31
 
32
- if field_def.respond_to?(:supported_content_type_ids=) && attrs[:supported_content_types].present?
33
- content_types = ContentType.where(handle: attrs[:supported_content_types])
34
- field_def.supported_content_type_ids = content_types.pluck(:id)
32
+ if field_def.respond_to?(:supported_content_type_ids=)
33
+ field_def.supported_content_type_ids = ContentType.where(handle: attrs[:supported_content_types] || []).pluck(:id)
35
34
  end
36
35
  end
36
+
37
+ private
38
+
39
+ # Changing a field's type discards its stored values: rows written under
40
+ # the old field class would otherwise linger as split-brain content.
41
+ def discard_stored_values(field_def, type)
42
+ field_def.fields.destroy_all if field_def.persisted? && field_def.type != classify_type(type)
43
+ end
37
44
  end
38
45
  end
39
46
  end
@@ -4,7 +4,7 @@ module Iron
4
4
 
5
5
  included do
6
6
  has_rank scoped_by: :schemable
7
- before_create :move_to_bottom
7
+ before_create :move_to_bottom, if: :no_rank?
8
8
  end
9
9
  end
10
10
  end
@@ -19,6 +19,8 @@ module Iron
19
19
  end
20
20
 
21
21
  def searchable=(value)
22
+ raise ActiveModel::UnknownAttributeError.new(self, "searchable") unless SEARCHABLE_TYPES.include?(self.class.name)
23
+
22
24
  self.metadata = (metadata || {}).merge("searchable" => ActiveModel::Type::Boolean.new.cast(value))
23
25
  end
24
26