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
@@ -0,0 +1,423 @@
1
+ ---
2
+ name: iron-cms
3
+ description: Build and manage CMS-driven Rails sites with Iron CMS. Use when modeling content types, editing db/cms/schema.json, creating or editing CMS content, or building site templates.
4
+ ---
5
+
6
+ # Iron CMS
7
+
8
+ Iron is a Rails engine CMS. The content model (content types, block definitions, fields,
9
+ locales) lives declaratively in `db/cms/schema.json`. Content (entries) lives in the
10
+ database and is managed through `iron:content:*` rake tasks. All commands below run from
11
+ the host app root.
12
+
13
+ ## The loop
14
+
15
+ Build a site by repeating this cycle:
16
+
17
+ 1. Model content in `db/cms/schema.json` (write it by hand on a fresh app, or run
18
+ `bin/rails iron:schema:dump` to materialize the current database schema).
19
+ 2. `bin/rails iron:schema:diff` — self-check what apply would change.
20
+ 3. `bin/rails iron:schema:apply` — upsert the schema into the database.
21
+ 4. Scaffold templates: `bin/rails g iron:pages` (once per app), then
22
+ `bin/rails g iron:template <handle>` for each web-published content type.
23
+ 5. Populate content with `bin/rails iron:content:create` / `iron:content:update`.
24
+ 6. Verify in the browser (`bin/dev`).
25
+ 7. Checkpoint: `bin/rails iron:seed:dump`, then commit `db/cms/schema.json` and
26
+ `db/seeds/iron.zip`.
27
+
28
+ ## Schema file reference
29
+
30
+ A complete `db/cms/schema.json`:
31
+
32
+ ```json
33
+ {
34
+ "version": "1.0",
35
+ "default_locale": "en",
36
+ "locales": [
37
+ { "code": "en", "name": "English" }
38
+ ],
39
+ "block_definitions": [
40
+ {
41
+ "handle": "hero_section",
42
+ "name": "Hero Section",
43
+ "field_definitions": [
44
+ { "handle": "title", "name": "Title", "type": "text_field", "metadata": { "required": true } },
45
+ { "handle": "subtitle", "name": "Subtitle", "type": "text_area" },
46
+ {
47
+ "handle": "image",
48
+ "name": "Image",
49
+ "type": "file",
50
+ "metadata": { "file_scope": "specific", "selected_presets": ["images"] }
51
+ }
52
+ ]
53
+ },
54
+ {
55
+ "handle": "quote_section",
56
+ "name": "Quote Section",
57
+ "field_definitions": [
58
+ { "handle": "quote", "name": "Quote", "type": "text_area" },
59
+ { "handle": "attribution", "name": "Attribution", "type": "text_field" }
60
+ ]
61
+ },
62
+ {
63
+ "handle": "seo",
64
+ "name": "SEO",
65
+ "field_definitions": [
66
+ { "handle": "title", "name": "SEO Title", "type": "text_field" },
67
+ { "handle": "description", "name": "SEO Description", "type": "text_area" }
68
+ ]
69
+ }
70
+ ],
71
+ "content_types": [
72
+ {
73
+ "handle": "author",
74
+ "name": "Author",
75
+ "type": "collection",
76
+ "icon": "user",
77
+ "title_field_handle": "name",
78
+ "field_definitions": [
79
+ { "handle": "name", "name": "Name", "type": "text_field", "metadata": { "required": true } },
80
+ { "handle": "bio", "name": "Bio", "type": "text_area" },
81
+ {
82
+ "handle": "photo",
83
+ "name": "Photo",
84
+ "type": "file",
85
+ "metadata": { "file_scope": "specific", "selected_presets": ["images"] }
86
+ }
87
+ ]
88
+ },
89
+ {
90
+ "handle": "page",
91
+ "name": "Page",
92
+ "type": "single",
93
+ "icon": "panels-top-left",
94
+ "web_publishing_enabled": true,
95
+ "title_field_handle": "title",
96
+ "web_page_title_field_handle": "title",
97
+ "field_definitions": [
98
+ { "handle": "title", "name": "Title", "type": "text_field", "metadata": { "required": true, "searchable": true } },
99
+ {
100
+ "handle": "sections",
101
+ "name": "Sections",
102
+ "type": "block_list",
103
+ "supported_block_definitions": ["hero_section", "quote_section"]
104
+ },
105
+ {
106
+ "handle": "seo",
107
+ "name": "SEO",
108
+ "type": "block",
109
+ "supported_block_definitions": ["seo"]
110
+ }
111
+ ]
112
+ },
113
+ {
114
+ "handle": "post",
115
+ "name": "Post",
116
+ "type": "collection",
117
+ "icon": "newspaper",
118
+ "web_publishing_enabled": true,
119
+ "base_path": "blog",
120
+ "title_field_handle": "title",
121
+ "web_page_title_field_handle": "title",
122
+ "field_definitions": [
123
+ { "handle": "title", "name": "Title", "type": "text_field", "metadata": { "required": true, "max_length": 120, "searchable": true } },
124
+ { "handle": "status", "name": "Status", "type": "text_field", "metadata": { "required": true, "allowed_values": ["draft", "published"] } },
125
+ { "handle": "body", "name": "Body", "type": "rich_text_area", "metadata": { "searchable": true } },
126
+ { "handle": "published_on", "name": "Published On", "type": "date" },
127
+ { "handle": "featured", "name": "Featured", "type": "boolean" },
128
+ {
129
+ "handle": "cover",
130
+ "name": "Cover",
131
+ "type": "file",
132
+ "metadata": { "file_scope": "specific", "selected_presets": ["images"] }
133
+ },
134
+ { "handle": "author", "name": "Author", "type": "reference", "supported_content_types": ["author"] },
135
+ { "handle": "related_posts", "name": "Related Posts", "type": "reference_list", "supported_content_types": ["post"] }
136
+ ]
137
+ }
138
+ ]
139
+ }
140
+ ```
141
+
142
+ Top-level keys: `version` (always `"1.0"`), `default_locale` (one of the locale codes),
143
+ `locales` (each `code` + `name`), `block_definitions`, `content_types`.
144
+
145
+ ### Content type attributes
146
+
147
+ - `handle` (required) — identifier used everywhere: tasks, templates, API.
148
+ - `name` (required) — display name in the admin.
149
+ - `type` (required) — `"single"` (exactly one entry: homepage, settings) or
150
+ `"collection"` (many entries: posts, projects).
151
+ - `description` — optional, shown in the admin.
152
+ - `icon` — **always pick one**; it identifies the type in the admin sidebar. Names come
153
+ from the Lucide icon set and `iron:schema:apply` rejects unknown ones. Good picks:
154
+ `file-text`, `files`, `newspaper`, `notebook-text`, `panels-top-left`,
155
+ `layout-template`, `image`, `images`, `calendar`, `user`, `users`, `tag`, `folder`,
156
+ `shopping-bag`, `package`, `map-pin`, `phone`, `mail`, `star`, `trophy`, `megaphone`,
157
+ `briefcase`, `building-2`, `book-open`, `message-square`, `circle-help`, `list`,
158
+ `layers`, `globe`, `video`, `music`, `camera`, `utensils`, `dumbbell`,
159
+ `graduation-cap`, `car`, `palette`, `quote`. (Full set:
160
+ `bin/rails runner "puts Iron::IconCatalog.all"`.)
161
+ - `web_publishing_enabled` — entries get public routes (omit for false).
162
+ - `base_path` — URL prefix for those routes (`"blog"` → `/blog/my-post`).
163
+ - `title_field_handle` — handle of the field naming entries in the admin and search.
164
+ - `web_page_title_field_handle` — handle of the field used as the web page title and
165
+ for route generation.
166
+ - `field_definitions` — array of fields; array order is the display order.
167
+
168
+ ### Field types
169
+
170
+ A field definition has `handle`, `name`, `type`, an optional `metadata` object holding
171
+ the per-type configuration (validation rules plus flags like `searchable`), and — for
172
+ block/reference types — a top-level `supported_block_definitions` or
173
+ `supported_content_types` array (these are siblings of `metadata`, not inside it).
174
+
175
+ | `type` | Content payload value | Validations (`metadata` keys) | Other `metadata` / extra keys |
176
+ | --- | --- | --- | --- |
177
+ | `text_field` | string | `required`, `allowed_values` (non-empty array of distinct strings), `min_length` / `max_length` (integers >= 1) | `searchable` |
178
+ | `text_area` | string | `required`, `min_length` / `max_length` | `searchable` |
179
+ | `rich_text_area` | HTML string | `required` | `searchable` |
180
+ | `number` | number | `required`, `min` / `max` (numbers) | — |
181
+ | `boolean` | `true` / `false` | — | — |
182
+ | `date` | ISO 8601 date or datetime string (`"2026-06-11"`) | `required` | — |
183
+ | `file` | `{ "_file": "<path or URL>" }` | `required`, `file_scope` (`"all"` default, or `"specific"`), `selected_presets` (with `"specific"`: non-empty array drawn from `images`, `videos`, `documents`, `audio`) | — |
184
+ | `reference` | entry id (integer) | `required` | `supported_content_types` (content type handles) |
185
+ | `reference_list` | array of entry ids | — | `supported_content_types` |
186
+ | `block` | object of nested field handles | — | `supported_block_definitions` (exactly one handle) |
187
+ | `block_list` | array of objects, each with `"_type": "<block_definition_handle>"` | — | `supported_block_definitions` (one or more handles) |
188
+
189
+ Validations are enforced on every write surface (admin form, `iron:content:*` tasks,
190
+ HTTP content API). A rejected write reports field-keyed errors — the CLI prints on
191
+ stderr:
192
+
193
+ ```json
194
+ { "errors": { "status": ["must be one of: draft, published"], "sections[0].title": ["can't be blank"] } }
195
+ ```
196
+
197
+ (the HTTP API returns the same flat hash without the `errors` wrapper). The messages
198
+ embed the allowed values and bounds needed to fix the payload.
199
+
200
+ ### Rules
201
+
202
+ - **Array order is display order.** There are no rank keys — reorder fields by
203
+ reordering the array.
204
+ - **Validation rules are plain `metadata` keys.** An omitted key means no constraint,
205
+ and on apply the file is authoritative: omitting a key clears a previously applied
206
+ rule. Unknown or inapplicable keys are rejected upfront by `iron:schema:apply`.
207
+ - **Omitted booleans are false.** Only write `web_publishing_enabled`, `required`,
208
+ `searchable`, etc. when true.
209
+ - **Handles are lowercase snake_case** (`a-z`, `0-9`, `_`). Reserved handles: `id`,
210
+ `base`, `_metadata`, `_type`.
211
+ - **Renaming a handle is a delete + create.** The old object stays in the database
212
+ until you apply with `PRUNE=1`, which destroys it along with its stored data.
213
+ - **Changing a field's `type` discards its stored values** — the old values are
214
+ unusable under the new type.
215
+ - `supported_block_definitions` / `supported_content_types` handles must exist in this
216
+ file (or already in the database — but with `PRUNE=1` only handles kept by the file
217
+ count, since everything else is destroyed).
218
+
219
+ ## Modeling playbook
220
+
221
+ - **Single vs collection**: use a single for one-off pages and global settings
222
+ (homepage, footer, site settings); a collection for repeatable content (posts,
223
+ projects, people).
224
+ - **Page builder**: define reusable sections as block definitions and give the page a
225
+ `block_list` field supporting them. Editors compose pages; templates switch on
226
+ `_type`.
227
+ - **Fixed structured groups** (an SEO panel, a CTA): a `block` field with exactly one
228
+ supported block definition.
229
+ - **Relations**: `reference` for one (post → author), `reference_list` for many.
230
+ - **Always set `title_field_handle`** to a text_field — it names entries in the admin
231
+ and powers search.
232
+ - **Web publishing**: `web_publishing_enabled: true` gives entries public routes,
233
+ auto-generated by parameterizing the web page title (set
234
+ `web_page_title_field_handle`). `base_path` nests them. An entry with the empty
235
+ route `""` serves the site index `/`.
236
+
237
+ ## Schema commands
238
+
239
+ ```bash
240
+ bin/rails iron:schema:dump # write the current database schema to db/cms/schema.json
241
+ bin/rails iron:schema:diff # show drift between file and database, both directions
242
+ bin/rails iron:schema:apply # upsert the file into the database
243
+ ```
244
+
245
+ Options (ENV vars):
246
+
247
+ - `SCHEMA_PATH=path/to/schema.json` — alternate file, all three tasks.
248
+ - `PRUNE=1` — apply only: ALSO destroy database objects missing from the file.
249
+ Destroying a content type cascades to all its entries; destroying a field
250
+ definition destroys its stored values. Without `PRUNE`, apply is additive and
251
+ updating: objects in the file are upserted by handle, database-only objects are
252
+ left alone (diff still lists them as removal candidates).
253
+ - `FORMAT=json` — machine-readable output.
254
+
255
+ Apply is idempotent — applying an unchanged file reports "Schema is in sync." Apply
256
+ also bootstraps a fresh database (no prior setup needed). In development, schema
257
+ changes made through the admin UI are automatically dumped back to
258
+ `db/cms/schema.json` (as long as the file exists), so file edits and UI edits mix
259
+ safely.
260
+
261
+ ## Content commands
262
+
263
+ ```bash
264
+ bin/rails iron:content:list HANDLE=post
265
+ bin/rails iron:content:get HANDLE=post ID=12 # or ROUTE=hello-world
266
+ bin/rails iron:content:create HANDLE=post ROUTE=hello-world
267
+ bin/rails iron:content:update HANDLE=post ID=12
268
+ bin/rails iron:content:delete HANDLE=post ID=12 # or ROUTE=hello-world
269
+ ```
270
+
271
+ The contract:
272
+
273
+ - `HANDLE=` is always required. An unknown handle aborts with the list of known
274
+ handles.
275
+ - For create/update, the payload is a JSON object of field handles → values (see the
276
+ field type table). Provide it inline with `PAYLOAD='{...}'`, from a file with
277
+ `FILE=payload.json`, or pipe it on stdin (heredoc — best for multi-line payloads).
278
+ - `ROUTE=` is not a content field: it identifies an entry (get/update/delete) or sets
279
+ the route at create time. Omit it to let Iron generate the route from the web page
280
+ title.
281
+ - `LOCALE=it` reads or writes the given locale; omitted, the account default locale
282
+ is used.
283
+ - Success prints the entry as pretty JSON on stdout (field values plus a `_metadata`
284
+ object with `content_type`, `locale`, `title`, `route`, `path`, timestamps) and
285
+ exits 0.
286
+ - **The printed entry is read-only output, not a resubmittable payload**: file fields
287
+ print as `{url, filename, ...}` objects and references print expanded — when
288
+ sending values back, rewrite them as `{"_file": ...}`, an entry id, or an array of
289
+ ids per the field type table.
290
+ - Validation failure prints `{"errors": {...}}` on stderr and exits 1. Error keys are
291
+ dotted/indexed field paths, e.g. `{"errors": {"sections[0].title": ["can't be blank"]}}`.
292
+ - Content writes are attributed to a dedicated System user, created automatically
293
+ on first use — no pre-existing user is required.
294
+
295
+ ### Create
296
+
297
+ ```bash
298
+ bin/rails iron:content:create HANDLE=post ROUTE=hello-world <<'JSON'
299
+ {
300
+ "title": "Hello World",
301
+ "status": "published",
302
+ "body": "<p>Our <strong>first</strong> post.</p>",
303
+ "published_on": "2026-06-11",
304
+ "featured": true,
305
+ "cover": { "_file": "app/assets/images/hello.jpg" },
306
+ "author": 3,
307
+ "related_posts": [4, 7]
308
+ }
309
+ JSON
310
+ ```
311
+
312
+ Short payloads inline:
313
+
314
+ ```bash
315
+ bin/rails iron:content:create HANDLE=author PAYLOAD='{"name": "Ada Lorne"}'
316
+ ```
317
+
318
+ ### Files
319
+
320
+ Wherever a file field expects a value, pass `{ "_file": "<source>" }` — a file path
321
+ (absolute or relative to the app root) or an `http(s)://` URL. The file is downloaded
322
+ if remote, uploaded to Active Storage, and attached.
323
+
324
+ ### Blocks and block lists
325
+
326
+ Block list items each require a `"_type"` naming the block definition; a `block`
327
+ field takes a plain object. Both replace their previous value wholesale:
328
+
329
+ ```bash
330
+ bin/rails iron:content:update HANDLE=page <<'JSON'
331
+ {
332
+ "title": "Home",
333
+ "sections": [
334
+ { "_type": "hero_section", "title": "Welcome", "image": { "_file": "https://example.com/hero.jpg" } },
335
+ { "_type": "quote_section", "quote": "Make it iron-clad.", "attribution": "Ada Lorne" }
336
+ ],
337
+ "seo": { "title": "Home", "description": "Welcome to our site." }
338
+ }
339
+ JSON
340
+ ```
341
+
342
+ ### Update
343
+
344
+ Updates are partial: omitted fields are untouched, an explicit `null` clears a field.
345
+ `reference_list`, `block`, and `block_list` values replace the whole previous value —
346
+ send the complete new value.
347
+
348
+ ```bash
349
+ bin/rails iron:content:update HANDLE=post ID=12 PAYLOAD='{"featured": false}'
350
+ ```
351
+
352
+ Singles upsert through update — no `ID`/`ROUTE`, and the entry is created on first
353
+ run (`iron:content:create` on a single is an error). The same id-less form fetches
354
+ it: `bin/rails iron:content:get HANDLE=page`.
355
+
356
+ ### Locales
357
+
358
+ ```bash
359
+ bin/rails iron:content:update HANDLE=page LOCALE=it PAYLOAD='{"title": "Casa"}'
360
+ bin/rails iron:content:get HANDLE=page LOCALE=it
361
+ ```
362
+
363
+ ### Delete
364
+
365
+ ```bash
366
+ bin/rails iron:content:delete HANDLE=post ID=12
367
+ ```
368
+
369
+ Prints `{"deleted": true, "id": 12}`.
370
+
371
+ ## Templates & rendering
372
+
373
+ ```bash
374
+ bin/rails g iron:pages # once: PagesController + iron_pages route + default view
375
+ bin/rails g iron:template post # per content type — run AFTER iron:schema:apply (reads the DB)
376
+ ```
377
+
378
+ Each web-published content type renders `app/views/templates/<handle>.html.erb`
379
+ (falling back to the generated `app/views/pages/show.html.erb`). Entry data is
380
+ exposed through `@entry.attributes`:
381
+
382
+ ```erb
383
+ <h1><%= @entry.attributes.title %></h1>
384
+ <%= raw @entry.attributes.body %>
385
+
386
+ <% @entry.attributes.sections.each do |section| %>
387
+ <% case section._type %>
388
+ <% when "hero_section" %>
389
+ <h2><%= section.title %></h2>
390
+ <%= iron_picture_tag section.image %>
391
+ <% when "quote_section" %>
392
+ <blockquote><%= section.quote %> — <%= section.attribution %></blockquote>
393
+ <% end %>
394
+ <% end %>
395
+ ```
396
+
397
+ - File fields return the attachment (or nil). Render images with `iron_picture_tag`
398
+ (responsive `<picture>` with AVIF/WebP variants) or `iron_image_tag`; include
399
+ `Iron::ImageHelper` in your `ApplicationHelper` first.
400
+ - Reference fields return a stub with `.id` and `._type`; load the entry for its
401
+ content: `Iron::Entry.find(@entry.attributes.author.id).attributes.name`.
402
+ - Link to web-published entries with `iron_entry_path(entry.attributes)` — note it
403
+ takes the presented attributes, not the record. The current page's path and title
404
+ live at `@entry.attributes._metadata.web.path` / `.title`.
405
+ - URLs for non-default locales are prefixed with the locale code (`/it/...`).
406
+
407
+ ## Checkpoint & deploy
408
+
409
+ - `bin/rails iron:seed:dump` exports the full CMS state (schema + entries + files) to
410
+ `db/seeds/iron.zip`. Commit it together with `db/cms/schema.json` after each
411
+ working increment.
412
+ - Add `Iron::Seed.load` to `db/seeds.rb`: `rails db:prepare` then bootstraps an empty
413
+ database (skipped when content types already exist). If `iron:install` already wrote
414
+ an `# == Iron CMS admin ==` block, append `Iron::Seed.load` below it rather than
415
+ re-adding admin provisioning — both read the same credentials. Configure with
416
+ `IRON_SEED_EMAIL` / `IRON_SEED_PASSWORD` (defaults `admin@example.com` / `password`
417
+ in local environments; both required outside them).
418
+ - To drive a remote instance over HTTP instead of rake: create a token with
419
+ `bin/rails iron:integration:create NAME=agent ROLE=member` (roles `reader`,
420
+ `member`, `administrator`; the token is shown once), send it as
421
+ `Authorization: Bearer <token>`, and fetch the OpenAPI spec at
422
+ `<iron mount path>/api/openapi.json` (e.g. `/admin/api/openapi.json` when the engine
423
+ is mounted at `/admin`) for the full HTTP content API.
@@ -0,0 +1,118 @@
1
+ require "rails/generators"
2
+
3
+ module Iron
4
+ module Generators
5
+ class InstallGenerator < Rails::Generators::Base
6
+ source_root File.expand_path("templates", __dir__)
7
+
8
+ MOUNT_PATH = "/admin".freeze
9
+ MOUNT_LINE = %(mount Iron::Engine => "#{MOUNT_PATH}").freeze
10
+ SEEDS_MARKER = "# == Iron CMS admin ==".freeze
11
+
12
+ class_option :skip_db, type: :boolean, default: false,
13
+ desc: "Skip the database steps (migrate, seed, schema apply)"
14
+
15
+ def mount_engine
16
+ route MOUNT_LINE unless routes_include?(MOUNT_LINE)
17
+ end
18
+
19
+ def install_active_storage
20
+ return if skip_db_steps?
21
+ return if migration_present?("create_active_storage_tables.active_storage.rb")
22
+
23
+ rails_command "active_storage:install", inline: true
24
+ end
25
+
26
+ def install_action_text
27
+ return if skip_db_steps?
28
+ return if migration_present?("create_action_text_tables.action_text.rb")
29
+
30
+ rails_command "action_text:install:migrations", inline: true
31
+ end
32
+
33
+ def install_iron_migrations
34
+ return if skip_db_steps?
35
+
36
+ rails_command "iron:install:migrations", inline: true
37
+ end
38
+
39
+ def create_schema_skeleton
40
+ copy_file "schema.json", "db/cms/schema.json"
41
+ end
42
+
43
+ def create_seeds
44
+ return if seeds_marker_present?
45
+
46
+ if File.exist?(seeds_path)
47
+ append_to_file "db/seeds.rb", seeds_block
48
+ else
49
+ copy_file "seeds.rb", "db/seeds.rb"
50
+ end
51
+ end
52
+
53
+ def wire_release_step
54
+ copy_file "iron_release.rake", "lib/tasks/iron_release.rake"
55
+ end
56
+
57
+ def install_agent_skill
58
+ invoke "iron:agents", [], quiet: true
59
+ end
60
+
61
+ def install_pages
62
+ invoke "iron:pages", [], quiet: true unless routes_include?("iron_pages")
63
+ end
64
+
65
+ def provision_admin
66
+ return if skip_db_steps?
67
+
68
+ rails_command "db:prepare", abort_on_failure: true
69
+ rails_command "db:seed", abort_on_failure: true
70
+ end
71
+
72
+ def display_instructions
73
+ say "\n=== Iron CMS Installed ===\n", :green
74
+ say "Created:"
75
+ say " - config/routes.rb mounts Iron::Engine at #{MOUNT_PATH}"
76
+ say " - db/cms/schema.json (minimal CMS schema skeleton — edit, then bin/rails iron:schema:apply)"
77
+ say " - db/seeds.rb provisions the first administrator (run by db:seed)"
78
+ say " - lib/tasks/iron_release.rake applies the CMS schema during db:prepare"
79
+ say " - .claude/skills/iron-cms/SKILL.md (the Iron CMS workflow for coding agents)"
80
+ say " - app/controllers/pages_controller.rb renders published content"
81
+ say "\nSet IRON_SEED_EMAIL and IRON_SEED_PASSWORD outside local environments, or seeding fails fast."
82
+ say "\nNext steps:"
83
+ say "1. Run bin/dev and open http://localhost:3000#{MOUNT_PATH} to sign in."
84
+ say "2. Deploy with bin/kamal deploy — db:prepare migrates and applies the CMS schema."
85
+ end
86
+
87
+ private
88
+
89
+ def skip_db_steps?
90
+ options[:skip_db] || options[:pretend]
91
+ end
92
+
93
+ def routes_path
94
+ File.join(destination_root, "config", "routes.rb")
95
+ end
96
+
97
+ def routes_include?(line)
98
+ File.exist?(routes_path) && File.read(routes_path).include?(line)
99
+ end
100
+
101
+ def seeds_path
102
+ File.join(destination_root, "db", "seeds.rb")
103
+ end
104
+
105
+ def seeds_marker_present?
106
+ File.exist?(seeds_path) && File.read(seeds_path).include?(SEEDS_MARKER)
107
+ end
108
+
109
+ def seeds_block
110
+ "\n#{File.read(find_in_source_paths("seeds.rb"))}"
111
+ end
112
+
113
+ def migration_present?(suffix)
114
+ Dir.glob(File.join(destination_root, "db", "migrate", "*_#{suffix}")).any?
115
+ end
116
+ end
117
+ end
118
+ end
@@ -0,0 +1,5 @@
1
+ if Rake::Task.task_defined?("db:prepare")
2
+ Rake::Task["db:prepare"].enhance do
3
+ Rake::Task["iron:schema:apply"].invoke
4
+ end
5
+ end
@@ -0,0 +1,12 @@
1
+ {
2
+ "version": "1.0",
3
+ "default_locale": "en",
4
+ "locales": [
5
+ {
6
+ "code": "en",
7
+ "name": "English"
8
+ }
9
+ ],
10
+ "block_definitions": [],
11
+ "content_types": []
12
+ }
@@ -0,0 +1,13 @@
1
+ # == Iron CMS admin ==
2
+ def iron_seed_credential(env_key, fallback)
3
+ ENV.fetch(env_key) do
4
+ Rails.env.local? ? fallback : raise("#{env_key} is required outside local environments")
5
+ end
6
+ end
7
+
8
+ if Iron::User.people.none?
9
+ Iron::FirstRun.create!(
10
+ email_address: iron_seed_credential("IRON_SEED_EMAIL", "admin@example.com"),
11
+ password: iron_seed_credential("IRON_SEED_PASSWORD", "password")
12
+ )
13
+ end
@@ -5,6 +5,9 @@ module Iron
5
5
  class PagesGenerator < Rails::Generators::Base
6
6
  source_root File.expand_path("templates", __dir__)
7
7
 
8
+ class_option :quiet, type: :boolean, default: false,
9
+ desc: "Suppress the closing instructions (set when composed by iron:install)"
10
+
8
11
  def create_controller
9
12
  template "pages_controller.rb", "app/controllers/pages_controller.rb"
10
13
  end
@@ -18,6 +21,8 @@ module Iron
18
21
  end
19
22
 
20
23
  def display_instructions
24
+ return if options[:quiet]
25
+
21
26
  say "\n=== Iron CMS Pages Setup Complete ===\n", :green
22
27
  say "The pages controller has been installed in your application.\n"
23
28
  say "\nNext steps:"
@@ -3,7 +3,7 @@ class PagesController < ApplicationController
3
3
 
4
4
  def show
5
5
  @content_type = Iron::ContentType.web_published.find_by_handle!(params[:content_type])
6
- @entry = Iron::SDK.send(@content_type.handle).find(params[:route])
6
+ @entry = @content_type.entries.find_by(route: params[:route])
7
7
 
8
8
  raise ActiveRecord::RecordNotFound unless @entry
9
9
 
@@ -1,4 +1,4 @@
1
- <h1><%%= @entry._metadata.web.title %></h1>
1
+ <h1><%%= @entry.attributes._metadata.web.title %></h1>
2
2
 
3
3
  <hr>
4
4
 
@@ -0,0 +1,9 @@
1
+ if (iron_path = ENV["IRON_GEM_PATH"])
2
+ gem "iron-cms", path: iron_path
3
+ else
4
+ gem "iron-cms"
5
+ end
6
+
7
+ after_bundle do
8
+ generate "iron:install"
9
+ end
data/lib/iron/cli.rb ADDED
@@ -0,0 +1,43 @@
1
+ require "thor"
2
+ require "bundler"
3
+
4
+ module Iron
5
+ class CLI < Thor
6
+ def self.exit_on_failure? = true
7
+
8
+ desc "new APP_PATH", "Create a new Rails application with Iron CMS installed"
9
+ long_desc <<~DESC
10
+ Scaffolds a fresh Rails application and wires in Iron CMS — mounting the engine,
11
+ installing migrations, dropping a schema skeleton, provisioning the first
12
+ administrator, and installing the agent skill.
13
+
14
+ Uses the released iron-cms gem. Pass --path to pin the new app to a local
15
+ checkout while developing the gem itself.
16
+ DESC
17
+ method_option :path, type: :string, aliases: "-p",
18
+ desc: "Pin the new app to a local Iron checkout instead of the released gem"
19
+ def new(app_path)
20
+ scaffold app_path, local_checkout: options[:path]
21
+ end
22
+
23
+ desc "version", "Print the Iron CMS version"
24
+ def version
25
+ require "iron/version"
26
+ say Iron::VERSION
27
+ end
28
+ map %w[-v --version] => :version
29
+
30
+ private
31
+ def scaffold(app_path, local_checkout:)
32
+ env = local_checkout ? { "IRON_GEM_PATH" => File.expand_path(local_checkout) } : {}
33
+ command = [ "rails", "new", app_path, "-m", template_path ]
34
+
35
+ scaffolded = Bundler.with_unbundled_env { Kernel.system(env, *command) }
36
+ abort "`rails new` failed — make sure Rails is installed (gem install rails)." unless scaffolded
37
+ end
38
+
39
+ def template_path
40
+ File.expand_path("../install/template.rb", __dir__)
41
+ end
42
+ end
43
+ end
data/lib/iron/version.rb CHANGED
@@ -1,3 +1,3 @@
1
1
  module Iron
2
- VERSION = "0.17.2"
2
+ VERSION = "0.18.0"
3
3
  end