iron-cms 0.17.3 → 0.18.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +88 -4
- data/app/assets/builds/iron.css +236 -106
- data/app/assets/tailwind/iron/lexxy.css +111 -87
- data/app/controllers/concerns/iron/schema_editing.rb +19 -0
- data/app/controllers/concerns/iron/web_page.rb +3 -1
- data/app/controllers/iron/api/schema/base_controller.rb +25 -0
- data/app/controllers/iron/api/schema/block_definitions_controller.rb +49 -0
- data/app/controllers/iron/api/schema/content_types_controller.rb +64 -0
- data/app/controllers/iron/api/schema/field_definitions_controller.rb +88 -0
- data/app/controllers/iron/api/schema/locales_controller.rb +52 -0
- data/app/controllers/iron/application_controller.rb +1 -1
- data/app/controllers/iron/block_definitions_controller.rb +1 -0
- data/app/controllers/iron/content_types_controller.rb +1 -0
- data/app/controllers/iron/field_definitions_controller.rb +2 -1
- data/app/controllers/iron/first_runs_controller.rb +1 -1
- data/app/controllers/iron/locales_controller.rb +1 -0
- data/app/controllers/iron/sessions_controller.rb +1 -1
- data/app/helpers/iron/avatar_helper.rb +24 -2
- data/app/models/iron/account.rb +1 -1
- data/app/models/iron/api/openapi_spec.rb +37 -15
- data/app/models/iron/block_definition/exportable.rb +1 -1
- data/app/models/iron/block_definition.rb +3 -1
- data/app/models/iron/content.rb +176 -0
- data/app/models/iron/content_type/exportable.rb +3 -1
- data/app/models/iron/content_type/importable.rb +1 -1
- data/app/models/iron/content_type.rb +6 -1
- data/app/models/iron/entry/content_assignable.rb +10 -2
- data/app/models/iron/entry/presentable.rb +1 -1
- data/app/models/iron/entry/web_publishable.rb +17 -3
- data/app/models/iron/exporter.rb +1 -26
- data/app/models/iron/field/length_constrained.rb +17 -0
- data/app/models/iron/field/validatable.rb +16 -0
- data/app/models/iron/field.rb +1 -1
- data/app/models/iron/field_definition/exportable.rb +3 -4
- data/app/models/iron/field_definition/importable.rb +16 -9
- data/app/models/iron/field_definition/ranked.rb +1 -1
- data/app/models/iron/field_definition/searchable.rb +2 -0
- data/app/models/iron/field_definition/validatable.rb +40 -0
- data/app/models/iron/field_definition/validations.rb +175 -0
- data/app/models/iron/field_definition.rb +1 -1
- data/app/models/iron/field_definitions/date.rb +2 -0
- data/app/models/iron/field_definitions/file.rb +3 -11
- data/app/models/iron/field_definitions/number.rb +2 -0
- data/app/models/iron/field_definitions/reference.rb +2 -0
- data/app/models/iron/field_definitions/rich_text_area.rb +1 -0
- data/app/models/iron/field_definitions/text_area.rb +2 -0
- data/app/models/iron/field_definitions/text_field.rb +1 -9
- data/app/models/iron/fields/block.rb +5 -1
- data/app/models/iron/fields/block_list.rb +5 -1
- data/app/models/iron/fields/date.rb +19 -2
- data/app/models/iron/fields/file.rb +5 -3
- data/app/models/iron/fields/number.rb +28 -1
- data/app/models/iron/fields/reference.rb +2 -0
- data/app/models/iron/fields/rich_text_area.rb +2 -0
- data/app/models/iron/fields/text_area.rb +4 -0
- data/app/models/iron/fields/text_field.rb +9 -5
- data/app/models/iron/importer.rb +1 -54
- data/app/models/iron/locale.rb +2 -0
- data/app/models/iron/schema/auto_dumpable.rb +26 -0
- data/app/models/iron/schema/diff.rb +194 -0
- data/app/models/iron/schema/validation.rb +214 -0
- data/app/models/iron/schema.rb +282 -0
- data/app/models/iron/seed.rb +5 -3
- data/app/models/iron/system.rb +7 -0
- data/app/models/iron/user/deactivatable.rb +5 -0
- data/app/models/iron/user.rb +18 -1
- data/app/views/iron/api/fields/_rich_text_area.json.jbuilder +2 -2
- data/app/views/iron/api/schema/block_definitions/_block_definition.json.jbuilder +4 -0
- data/app/views/iron/api/schema/block_definitions/index.json.jbuilder +1 -0
- data/app/views/iron/api/schema/block_definitions/show.json.jbuilder +1 -0
- data/app/views/iron/api/schema/content_types/_content_type.json.jbuilder +5 -0
- data/app/views/iron/api/schema/content_types/index.json.jbuilder +1 -0
- data/app/views/iron/api/schema/content_types/show.json.jbuilder +1 -0
- data/app/views/iron/api/schema/field_definitions/_field_definition.json.jbuilder +7 -0
- data/app/views/iron/api/schema/field_definitions/show.json.jbuilder +1 -0
- data/app/views/iron/api/schema/locales/_locale.json.jbuilder +4 -0
- data/app/views/iron/api/schema/locales/index.json.jbuilder +1 -0
- data/app/views/iron/api/schema/locales/show.json.jbuilder +1 -0
- data/app/views/iron/block_definitions/_empty_state.html.erb +5 -3
- data/app/views/iron/block_definitions/_form.html.erb +3 -1
- data/app/views/iron/block_definitions/edit.html.erb +19 -17
- data/app/views/iron/block_definitions/index.html.erb +7 -3
- data/app/views/iron/block_definitions/show.html.erb +14 -8
- data/app/views/iron/content_types/_content_type.html.erb +1 -1
- data/app/views/iron/content_types/_empty_state.html.erb +5 -3
- data/app/views/iron/content_types/_form.html.erb +12 -8
- data/app/views/iron/content_types/edit.html.erb +19 -17
- data/app/views/iron/content_types/index.html.erb +7 -3
- data/app/views/iron/content_types/show.html.erb +14 -8
- data/app/views/iron/entries/_empty_state.html.erb +1 -1
- data/app/views/iron/entries/edit.html.erb +1 -1
- data/app/views/iron/entries/entry.html.erb +1 -1
- data/app/views/iron/entries/fields/_block.html.erb +4 -0
- data/app/views/iron/entries/fields/_block_list.html.erb +2 -0
- data/app/views/iron/entries/fields/_boolean.html.erb +1 -0
- data/app/views/iron/entries/fields/_date.html.erb +3 -2
- data/app/views/iron/entries/fields/_field_errors.html.erb +7 -0
- data/app/views/iron/entries/fields/_field_label.html.erb +8 -0
- data/app/views/iron/entries/fields/_file.html.erb +4 -19
- data/app/views/iron/entries/fields/_number.html.erb +6 -2
- data/app/views/iron/entries/fields/_reference.html.erb +2 -1
- data/app/views/iron/entries/fields/_reference_list.html.erb +2 -0
- data/app/views/iron/entries/fields/_rich_text_area.html.erb +2 -1
- data/app/views/iron/entries/fields/_text_area.html.erb +6 -2
- data/app/views/iron/entries/fields/_text_field.html.erb +5 -12
- data/app/views/iron/field_definitions/_field_definition.html.erb +51 -29
- data/app/views/iron/field_definitions/date/_form.html.erb +5 -1
- data/app/views/iron/field_definitions/edit.html.erb +10 -8
- data/app/views/iron/field_definitions/file/_form.html.erb +6 -0
- data/app/views/iron/field_definitions/new.html.erb +5 -3
- data/app/views/iron/field_definitions/number/_form.html.erb +23 -1
- data/app/views/iron/field_definitions/reference/_form.html.erb +5 -0
- data/app/views/iron/field_definitions/rich_text_area/_form.html.erb +5 -0
- data/app/views/iron/field_definitions/text_area/_form.html.erb +23 -0
- data/app/views/iron/field_definitions/text_field/_form.html.erb +20 -1
- data/app/views/iron/home/_content_types.html.erb +1 -1
- data/app/views/iron/locales/_form.html.erb +5 -3
- data/app/views/iron/locales/edit.html.erb +1 -1
- data/app/views/iron/locales/index.html.erb +12 -6
- data/app/views/iron/shared/_schema_lock_badge.html.erb +19 -0
- data/config/locales/en.yml +13 -1
- data/config/locales/it.yml +18 -1
- data/config/routes.rb +11 -0
- data/db/migrate/20260612131538_create_iron_systems.rb +9 -0
- data/exe/iron +5 -0
- data/lib/generators/iron/agents/agents_generator.rb +52 -0
- data/lib/generators/iron/agents/templates/AGENTS.md +24 -0
- data/lib/generators/iron/agents/templates/SKILL.md +423 -0
- data/lib/generators/iron/install/install_generator.rb +118 -0
- data/lib/generators/iron/install/templates/iron_release.rake +5 -0
- data/lib/generators/iron/install/templates/schema.json +12 -0
- data/lib/generators/iron/install/templates/seeds.rb +13 -0
- data/lib/generators/iron/pages/pages_generator.rb +5 -0
- data/lib/generators/iron/pages/templates/pages_controller.rb +2 -2
- data/lib/generators/iron/pages/templates/show.html.erb +1 -1
- data/lib/install/template.rb +9 -0
- data/lib/iron/cli.rb +43 -0
- data/lib/iron/published_page_constraint.rb +33 -0
- data/lib/iron/routing.rb +2 -26
- data/lib/iron/version.rb +1 -1
- data/lib/tasks/iron_content.rake +82 -0
- data/lib/tasks/iron_schema.rake +45 -0
- 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,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,9 +3,9 @@ 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 =
|
|
6
|
+
@entry = @content_type.entries.find_by(route: params[:route])
|
|
7
7
|
|
|
8
|
-
raise ActiveRecord::RecordNotFound unless @entry
|
|
8
|
+
raise ActiveRecord::RecordNotFound unless @entry&.published_in?(Iron::Current.locale)
|
|
9
9
|
|
|
10
10
|
render "templates/#{@content_type.handle}"
|
|
11
11
|
rescue ActionView::MissingTemplate
|
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
|