railspress-engine 0.1.2 → 1.0.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 (140) hide show
  1. checksums.yaml +4 -4
  2. data/LICENSE +20 -0
  3. data/README.md +193 -25
  4. data/app/assets/javascripts/railspress/admin.js +39 -0
  5. data/app/assets/javascripts/railspress/markdown_mode.js +343 -0
  6. data/app/assets/stylesheets/application.css +0 -0
  7. data/app/assets/stylesheets/railspress/admin/badges.css +70 -0
  8. data/app/assets/stylesheets/railspress/admin/base.css +25 -0
  9. data/app/assets/stylesheets/railspress/admin/buttons.css +140 -0
  10. data/app/assets/stylesheets/railspress/admin/cards.css +52 -0
  11. data/app/assets/stylesheets/railspress/admin/components/exports.css +55 -0
  12. data/app/assets/stylesheets/railspress/admin/components/focal_point.css +801 -0
  13. data/app/assets/stylesheets/railspress/admin/components/imports.css +144 -0
  14. data/app/assets/stylesheets/railspress/admin/components/lexxy.css +145 -0
  15. data/app/assets/stylesheets/railspress/admin/filters.css +73 -0
  16. data/app/assets/stylesheets/railspress/admin/flash.css +26 -0
  17. data/app/assets/stylesheets/railspress/admin/forms.css +459 -0
  18. data/app/assets/stylesheets/railspress/admin/layout.css +256 -0
  19. data/app/assets/stylesheets/railspress/admin/lists.css +24 -0
  20. data/app/assets/stylesheets/railspress/admin/page.css +111 -0
  21. data/app/assets/stylesheets/railspress/admin/responsive.css +174 -0
  22. data/app/assets/stylesheets/railspress/admin/stats.css +43 -0
  23. data/app/assets/stylesheets/railspress/admin/tables.css +163 -0
  24. data/app/assets/stylesheets/railspress/admin/utilities.css +202 -0
  25. data/app/assets/stylesheets/railspress/admin/variables.css +58 -0
  26. data/app/assets/stylesheets/railspress/application.css +44 -13
  27. data/app/controllers/railspress/admin/base_controller.rb +6 -3
  28. data/app/controllers/railspress/admin/categories_controller.rb +1 -1
  29. data/app/controllers/railspress/admin/cms_transfers_controller.rb +49 -0
  30. data/app/controllers/railspress/admin/content_element_versions_controller.rb +12 -0
  31. data/app/controllers/railspress/admin/content_elements_controller.rb +143 -0
  32. data/app/controllers/railspress/admin/content_groups_controller.rb +69 -0
  33. data/app/controllers/railspress/admin/dashboard_controller.rb +6 -0
  34. data/app/controllers/railspress/admin/entities_controller.rb +157 -0
  35. data/app/controllers/railspress/admin/exports_controller.rb +55 -0
  36. data/app/controllers/railspress/admin/focal_points_controller.rb +100 -0
  37. data/app/controllers/railspress/admin/imports_controller.rb +63 -0
  38. data/app/controllers/railspress/admin/posts_controller.rb +58 -4
  39. data/app/controllers/railspress/admin/prototypes_controller.rb +30 -0
  40. data/app/controllers/railspress/admin/tags_controller.rb +1 -1
  41. data/app/controllers/railspress/application_controller.rb +1 -0
  42. data/app/helpers/railspress/admin_helper.rb +733 -0
  43. data/app/helpers/railspress/application_helper.rb +23 -0
  44. data/app/helpers/railspress/cms_helper.rb +338 -0
  45. data/app/javascript/railspress/controllers/cms_inline_editor_controller.js +130 -0
  46. data/app/javascript/railspress/controllers/content_element_form_controller.js +15 -0
  47. data/app/javascript/railspress/controllers/crop_controller.js +224 -0
  48. data/app/javascript/railspress/controllers/dropzone_controller.js +261 -0
  49. data/app/javascript/railspress/controllers/focal_point_controller.js +124 -0
  50. data/app/javascript/railspress/controllers/image_section_controller.js +94 -0
  51. data/app/javascript/railspress/controllers/index.js +37 -0
  52. data/app/javascript/railspress/index.js +59 -0
  53. data/app/jobs/railspress/export_posts_job.rb +16 -0
  54. data/app/jobs/railspress/import_posts_job.rb +44 -0
  55. data/app/models/concerns/railspress/has_focal_point.rb +242 -0
  56. data/app/models/concerns/railspress/soft_deletable.rb +23 -0
  57. data/app/models/concerns/railspress/taggable.rb +23 -0
  58. data/app/models/railspress/content_element.rb +103 -0
  59. data/app/models/railspress/content_element_version.rb +32 -0
  60. data/app/models/railspress/content_group.rb +39 -0
  61. data/app/models/railspress/export.rb +67 -0
  62. data/app/models/railspress/focal_point.rb +70 -0
  63. data/app/models/railspress/import.rb +65 -0
  64. data/app/models/railspress/post.rb +102 -15
  65. data/app/models/railspress/post_export_processor.rb +162 -0
  66. data/app/models/railspress/post_import_processor.rb +382 -0
  67. data/app/models/railspress/tag.rb +7 -2
  68. data/app/models/railspress/tagging.rb +11 -0
  69. data/app/services/railspress/content_export_service.rb +122 -0
  70. data/app/services/railspress/content_import_service.rb +228 -0
  71. data/app/views/action_text/attachables/_remote_image.html.erb +8 -0
  72. data/app/views/active_storage/blobs/_blob.html.erb +1 -1
  73. data/app/views/layouts/railspress/admin.html.erb +3 -1
  74. data/app/views/railspress/admin/categories/index.html.erb +11 -15
  75. data/app/views/railspress/admin/cms_transfers/show.html.erb +167 -0
  76. data/app/views/railspress/admin/content_element_versions/show.html.erb +42 -0
  77. data/app/views/railspress/admin/content_elements/_form.html.erb +71 -0
  78. data/app/views/railspress/admin/content_elements/_inline_form.html.erb +32 -0
  79. data/app/views/railspress/admin/content_elements/_inline_form_frame.html.erb +6 -0
  80. data/app/views/railspress/admin/content_elements/edit.html.erb +6 -0
  81. data/app/views/railspress/admin/content_elements/index.html.erb +74 -0
  82. data/app/views/railspress/admin/content_elements/new.html.erb +6 -0
  83. data/app/views/railspress/admin/content_elements/show.html.erb +124 -0
  84. data/app/views/railspress/admin/content_groups/_form.html.erb +9 -0
  85. data/app/views/railspress/admin/content_groups/edit.html.erb +6 -0
  86. data/app/views/railspress/admin/content_groups/index.html.erb +42 -0
  87. data/app/views/railspress/admin/content_groups/new.html.erb +6 -0
  88. data/app/views/railspress/admin/content_groups/show.html.erb +92 -0
  89. data/app/views/railspress/admin/dashboard/index.html.erb +36 -1
  90. data/app/views/railspress/admin/entities/_form.html.erb +53 -0
  91. data/app/views/railspress/admin/entities/edit.html.erb +4 -0
  92. data/app/views/railspress/admin/entities/index.html.erb +74 -0
  93. data/app/views/railspress/admin/entities/new.html.erb +4 -0
  94. data/app/views/railspress/admin/entities/show.html.erb +117 -0
  95. data/app/views/railspress/admin/exports/show.html.erb +62 -0
  96. data/app/views/railspress/admin/imports/_instructions.html.erb +56 -0
  97. data/app/views/railspress/admin/imports/show.html.erb +137 -0
  98. data/app/views/railspress/admin/posts/_form.html.erb +102 -28
  99. data/app/views/railspress/admin/posts/_post_row.html.erb +40 -0
  100. data/app/views/railspress/admin/posts/index.html.erb +47 -36
  101. data/app/views/railspress/admin/posts/show.html.erb +55 -19
  102. data/app/views/railspress/admin/prototypes/image_section.html.erb +42 -0
  103. data/app/views/railspress/admin/shared/_dropzone.html.erb +84 -0
  104. data/app/views/railspress/admin/shared/_focal_point_editor.html.erb +102 -0
  105. data/app/views/railspress/admin/shared/_image_section.html.erb +159 -0
  106. data/app/views/railspress/admin/shared/_image_section_compact.html.erb +90 -0
  107. data/app/views/railspress/admin/shared/_image_section_editor.html.erb +171 -0
  108. data/app/views/railspress/admin/shared/_image_section_v2.html.erb +205 -0
  109. data/app/views/railspress/admin/shared/_sidebar.html.erb +73 -5
  110. data/app/views/railspress/admin/tags/index.html.erb +12 -16
  111. data/config/brakeman.ignore +18 -0
  112. data/config/importmap.rb +20 -0
  113. data/config/routes.rb +62 -1
  114. data/db/migrate/20241218000004_create_railspress_post_tags.rb +1 -1
  115. data/db/migrate/20241218000005_create_railspress_imports.rb +21 -0
  116. data/db/migrate/20241218000006_create_railspress_exports.rb +20 -0
  117. data/db/migrate/20241218000007_create_railspress_taggings.rb +20 -0
  118. data/db/migrate/20241218000008_drop_railspress_post_tags.rb +14 -0
  119. data/db/migrate/20241218000010_add_reading_time_to_railspress_posts.rb +5 -0
  120. data/db/migrate/20250105000002_create_railspress_focal_points.rb +20 -0
  121. data/db/migrate/20260206000001_create_railspress_content_groups.rb +18 -0
  122. data/db/migrate/20260206000002_create_railspress_content_elements.rb +21 -0
  123. data/db/migrate/20260206000003_create_railspress_content_element_versions.rb +20 -0
  124. data/db/migrate/20260207000001_add_unique_index_to_content_elements.rb +11 -0
  125. data/db/migrate/20260211112812_add_image_hint_to_railspress_content_elements.rb +7 -0
  126. data/db/migrate/20260211154040_add_required_to_railspress_content_elements.rb +5 -0
  127. data/lib/generators/railspress/entity/entity_generator.rb +89 -0
  128. data/lib/generators/railspress/entity/templates/migration.rb.tt +13 -0
  129. data/lib/generators/railspress/entity/templates/model.rb.tt +21 -0
  130. data/lib/generators/railspress/install/install_generator.rb +11 -20
  131. data/lib/generators/railspress/install/templates/initializer.rb +29 -0
  132. data/lib/railspress/engine.rb +38 -0
  133. data/lib/railspress/entity.rb +239 -0
  134. data/lib/railspress/version.rb +1 -1
  135. data/lib/railspress.rb +198 -8
  136. data/lib/tasks/railspress_tasks.rake +49 -4
  137. metadata +203 -14
  138. data/MIT-LICENSE +0 -20
  139. data/app/assets/stylesheets/railspress/admin.css +0 -1207
  140. data/app/models/railspress/post_tag.rb +0 -8
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 23a09596799a0a89eefd920154a5aefdcae1a4247ebbfb8f161781a87adaa462
4
- data.tar.gz: 3b8b24840927df71dedc373457eeb082cf429932e6542eceeec06cf59195cfea
3
+ metadata.gz: 00fa3ef64f615a9f1a7bb10c79085c50657dfa462e3f61e57002dfdc9e181d5e
4
+ data.tar.gz: c241000c9554ac492a6fed87f1d1bded59ea3257541d14e6a855c3102463cf03
5
5
  SHA512:
6
- metadata.gz: 4ad75a1e6057939610e32fef6b4add2a5d4eb4b5ff47621801f45a74414205c0d30ac5e308e21b612e26d774d19f6cd488e7655dd4626772a8c7e837997680d2
7
- data.tar.gz: 4f011c5faf57b329fb81ae904ff2da1ef0df5a373b7e3c41c31c16af5bc661eeddb67430daf8e8def0b9cc4f899802217287ebbfeb235e51cf1567d0067aa0d1
6
+ metadata.gz: aa8e9ad5cf137778317c7c94bf9c75fdffe5a32356a3a2681ef6278868e16d16d9b9686edab3bcd4c190136f8fcfc0cefab0799d50fed20c37a74978cfea7e7b
7
+ data.tar.gz: 2fe456078ff4a52b014a43b56f3ad8e434b62f001ac42b07dd5cc5eb335af88a5ae428c13863cf467df69e0c749bd74792b7590d94ccacc6e63b2aa528fc9089
data/LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ O'Saasy License Agreement - https://osaasy.dev/
2
+
3
+ Copyright © 2026, Avi Flombaum (https://avi.nyc).
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
6
+
7
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
8
+ No licensee or downstream recipient may use the Software (including any modified or derivative versions) to directly compete with the original Licensor by offering it to third parties as a hosted, managed, or Software-as-a-Service (SaaS) product or cloud service where the primary value of the service is the functionality of the Software itself.
9
+
10
+ THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
11
+
12
+ --
13
+
14
+ Trademarks
15
+
16
+ The Rails trademarks are the intellectual property of David Heinemeier Hansson,
17
+ and exclusively licensed to the Rails Foundation. Uses of "Rails" and "Ruby on
18
+ Rails" in this project are for identification purposes only and do not imply an
19
+ endorsement by or affiliation with Rails, the trademark owner, or the Rails
20
+ Foundation.
data/README.md CHANGED
@@ -1,60 +1,224 @@
1
- # RailsPress
1
+ <p align="center">
2
+ <strong>RailsPress</strong><br>
3
+ A mountable blog + CMS engine for Rails 8
4
+ </p>
2
5
 
3
- A simple blog engine for Rails 8 applications.
6
+ <p align="center">
7
+ <a href="https://rubygems.org/gems/railspress-engine"><img src="https://img.shields.io/gem/v/railspress-engine.svg?style=flat" alt="Gem Version"></a>
8
+ <img src="https://img.shields.io/badge/Rails-8.1%2B-red.svg?style=flat" alt="Rails 8.1+">
9
+ <img src="https://img.shields.io/badge/Ruby-3.3%2B-red.svg?style=flat" alt="Ruby 3.3+">
10
+ <a href="https://osaasy.dev/"><img src="https://img.shields.io/badge/License-O'Saasy-blue.svg?style=flat" alt="License"></a>
11
+ </p>
4
12
 
5
- ## Features
13
+ ---
14
+
15
+ RailsPress is a mountable Rails engine that gives your app a complete content management system — namespaced and isolated so it stays out of your way.
16
+
17
+ It manages three kinds of content:
18
+
19
+ ## Posts, Entities, and Blocks
20
+
21
+ ### Posts
6
22
 
7
- - Blog posts with rich text editing (ActionText)
8
- - Categories and tags
23
+ Your blog. Chronological content published over time — articles, news, announcements. Categories, tags, draft/published workflow.
24
+
25
+ - Rich text editing with [Lexxy](https://github.com/aviflombaum/lexxy) + markdown mode toggle
26
+ - Categories, tags, draft/published workflow
9
27
  - SEO metadata (meta title, meta description)
10
- - Draft/published workflow with automatic publish timestamps
11
- - Admin interface for content management
28
+ - Reading time auto-calculation
29
+ - Header images with focal point cropping
30
+ - Import/export (markdown + YAML frontmatter)
31
+
32
+ ### Entities
33
+
34
+ Your pages of structured content. A portfolio with projects, a collection of case studies, a resources page with links — anything with its own schema that isn't a blog post.
35
+
36
+ You define a regular ActiveRecord model, include `Railspress::Entity`, and RailsPress gives it a full admin interface with CRUD, search, pagination, image uploads, and tagging — no scaffolding or custom views required.
37
+
38
+ - Define fields with `railspress_fields` DSL or let RailsPress auto-detect from your schema
39
+ - Supports string, text, rich text, boolean, datetime, attachments, array fields, and more
40
+ - Focal point image cropping for any attachment
41
+ - Polymorphic tagging
42
+ - Custom index columns, searchable fields, scopes
43
+ - Generator: `rails generate railspress:entity Project title:string description:text`
44
+
45
+ ### Blocks
46
+
47
+ The copy and images on your site itself. Your homepage hero headline, an "About Us" blurb, a call-to-action, a footer tagline — the content that normally lives hardcoded in templates and requires a developer to change.
48
+
49
+ Blocks are organized into **groups** (e.g., "Homepage Hero", "Contact Info") and each block is either text or an image. You reference them in your views and they become editable in the admin — or inline, right on the page.
50
+
51
+ - Chainable Ruby API: `Railspress::CMS.find("Hero").load("headline").value`
52
+ - View helpers: `cms_value("Hero", "headline")` and `cms_element("Hero", "headline")`
53
+ - Inline editing — right-click any `cms_element` in the frontend to edit in place
54
+ - Auto-versioning with full audit trail
55
+ - Required blocks that can't be accidentally deleted
56
+ - Image blocks with upload, hints, and focal points
57
+ - Export/import block groups as ZIP
58
+
59
+ ### Why all three?
60
+
61
+ Most content doesn't fit neatly into "blog posts." A portfolio piece isn't a post. A homepage headline isn't a post. RailsPress gives you the right tool for each kind of content instead of forcing everything through one model.
62
+
63
+ ## Features
64
+
65
+ **Admin Interface**
66
+ - Dashboard with content stats and recent activity
67
+ - Full CRUD for posts, categories, tags, block groups, blocks, and entities
68
+ - Drag-and-drop image uploads with progress
69
+ - Collapsible sidebar, responsive design
70
+ - Vanilla CSS with BEM naming (`rp-` prefix) — no framework dependencies
71
+
72
+ **Developer Experience**
73
+ - Focal point image concern for any model: `focal_point_image :cover_photo`
74
+ - CSS variable theming
75
+ - Generators for installation and custom entities
12
76
 
13
77
  ## Requirements
14
78
 
15
- - Rails 8.0+
79
+ - Rails 8.1+
16
80
  - Ruby 3.3+
17
- - ActionText (for rich text)
18
- - Active Storage (for image uploads)
81
+ - ActionText
82
+ - Active Storage
19
83
 
20
84
  ## Installation
21
85
 
86
+ Ensure ActionText and Active Storage are installed:
87
+
88
+ ```bash
89
+ rails action_text:install
90
+ rails active_storage:install
91
+ ```
92
+
22
93
  Add to your Gemfile:
23
94
 
24
95
  ```ruby
25
- gem "railspress"
96
+ gem "railspress-engine"
26
97
  ```
27
98
 
28
- Install the gem and copy migrations:
99
+ Run the install generator:
29
100
 
30
101
  ```bash
31
102
  bundle install
32
- rails railspress:install:migrations
103
+ rails generate railspress:install
33
104
  rails db:migrate
34
105
  ```
35
106
 
36
- Mount the engine in your routes:
107
+ Mount the engine (the install generator does this automatically):
37
108
 
38
109
  ```ruby
39
110
  # config/routes.rb
40
111
  Rails.application.routes.draw do
41
- mount Railspress::Engine => "/blog", as: :railspress
112
+ mount Railspress::Engine => "/railspress"
42
113
  end
43
114
  ```
44
115
 
45
- ## Usage
116
+ ## Authentication
117
+
118
+ The admin interface is publicly accessible by default. Add authentication before deploying:
46
119
 
47
- Access the admin interface at `/blog/admin`.
120
+ ```ruby
121
+ # app/controllers/application_controller.rb
122
+ class ApplicationController < ActionController::Base
123
+ before_action :authenticate_user!, if: :railspress_admin?
48
124
 
49
- From there you can:
50
- - Create and manage blog posts with rich text content
51
- - Organize posts with categories
52
- - Tag posts (enter tags as comma-separated values)
53
- - Save posts as drafts or publish them
125
+ private
54
126
 
55
- ## Development
127
+ def railspress_admin?
128
+ request.path.start_with?("/railspress/admin")
129
+ end
130
+ end
131
+ ```
132
+
133
+ See [CONFIGURING.md](docs/CONFIGURING.md) for more authentication patterns including Devise integration.
134
+
135
+ ## Quick Start
136
+
137
+ Access the admin at `/railspress/admin`. From there you can manage posts, entities, and blocks.
138
+
139
+ ### Posts
140
+
141
+ Create posts with rich text, categories, tags, and header images. Build your own frontend controllers and views — see the [Blogging guide](docs/BLOGGING.md).
142
+
143
+ ### Entities
56
144
 
57
- After checking out the repo:
145
+ Generate a model and register it:
146
+
147
+ ```bash
148
+ rails generate railspress:entity Project title:string client:string description:text
149
+ ```
150
+
151
+ ```ruby
152
+ class Project < ApplicationRecord
153
+ include Railspress::Entity
154
+
155
+ railspress_config do |c|
156
+ c.admin_title = "Projects"
157
+ c.searchable_columns = [:title, :client]
158
+ end
159
+ end
160
+ ```
161
+
162
+ It now has a full admin interface at `/railspress/admin/entities/projects`. See the [Entity System guide](docs/ENTITIES.md).
163
+
164
+ ### Blocks
165
+
166
+ Set up inline editing for admins:
167
+
168
+ ```ruby
169
+ # config/initializers/railspress.rb
170
+ Railspress.configure do |config|
171
+ config.inline_editing_check = ->(ctx) { ctx.current_user&.admin? }
172
+ end
173
+ ```
174
+
175
+ Reference blocks in your views:
176
+
177
+ ```erb
178
+ <%# Raw value (no editing wrapper) %>
179
+ <h1><%= cms_value("Homepage", "headline") %></h1>
180
+
181
+ <%# With inline editing (admins can right-click to edit in place) %>
182
+ <h1><%= cms_element("Homepage", "headline") %></h1>
183
+
184
+ <p><%= cms_element("Homepage", "subheadline") %></p>
185
+ <%= image_tag cms_value("Homepage", "hero_image"), alt: "Hero" %>
186
+ ```
187
+
188
+ Create the "Homepage" group and its blocks in the admin at `/railspress/admin/content_groups`. See the [Inline Editing guide](docs/INLINE_EDITING.md).
189
+
190
+ ## Generators
191
+
192
+ ```bash
193
+ rails generate railspress:install # Full setup
194
+ rails generate railspress:entity Project title:string # Add a managed entity
195
+ ```
196
+
197
+ ## Documentation
198
+
199
+ **Posts, Entities, and Blocks:**
200
+
201
+ | Guide | Description |
202
+ |-------|-------------|
203
+ | [Building a Blog](docs/BLOGGING.md) | Frontend views, RSS, SEO for posts |
204
+ | [Entity System](docs/ENTITIES.md) | Structured content pages (portfolios, case studies, etc.) |
205
+ | [Blocks & Inline Editing](docs/INLINE_EDITING.md) | Editable site copy and images |
206
+
207
+ **Everything else:**
208
+
209
+ | Guide | Description |
210
+ |-------|-------------|
211
+ | [Reference](docs/README.md) | Models, routes, and API reference |
212
+ | [Configuring](docs/CONFIGURING.md) | Authors, images, inline editing, and all options |
213
+ | [Image Focal Points](docs/image-focal-point-system.md) | Smart cropping with focal points |
214
+ | [Import/Export](docs/IMPORT_EXPORT.md) | Bulk operations for posts and CMS content |
215
+ | [Theming](docs/THEMING.md) | CSS variable customization |
216
+ | [Admin Helpers](docs/ADMIN_HELPERS.md) | View helper reference |
217
+ | [Troubleshooting](docs/TROUBLESHOOTING.md) | Common issues |
218
+ | [Upgrading](docs/UPGRADING.md) | Version upgrades and migrations |
219
+ | [Changelog](CHANGELOG.md) | Version history |
220
+
221
+ ## Development
58
222
 
59
223
  ```bash
60
224
  bundle install
@@ -64,4 +228,8 @@ bundle exec rspec
64
228
 
65
229
  ## License
66
230
 
67
- The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
231
+ Available as open source under the terms of the [O'Saasy License](https://osaasy.dev/).
232
+
233
+ ## Trademarks
234
+
235
+ The Rails trademarks are the intellectual property of David Heinemeier Hansson, and exclusively licensed to the Rails Foundation. Uses of "Rails" and "Ruby on Rails" in this project are for identification purposes only and do not imply an endorsement by or affiliation with Rails, the trademark owner, or the Rails Foundation.
@@ -6,6 +6,44 @@
6
6
  (function() {
7
7
  'use strict';
8
8
 
9
+ // ============================================
10
+ // Sidebar Collapse Toggle
11
+ // ============================================
12
+
13
+ function initSidebarCollapse() {
14
+ var sidebar = document.querySelector('.rp-sidebar');
15
+ var layout = document.querySelector('.rp-admin-layout');
16
+ var toggle = document.querySelector('.rp-sidebar-toggle');
17
+
18
+ if (!sidebar || !toggle || !layout) return;
19
+
20
+ var STORAGE_KEY = 'rp-sidebar-collapsed';
21
+
22
+ function isCollapsed() {
23
+ return localStorage.getItem(STORAGE_KEY) === 'true';
24
+ }
25
+
26
+ function setCollapsed(collapsed) {
27
+ if (collapsed) {
28
+ sidebar.classList.add('rp-sidebar--collapsed');
29
+ layout.classList.add('rp-admin-layout--sidebar-collapsed');
30
+ } else {
31
+ sidebar.classList.remove('rp-sidebar--collapsed');
32
+ layout.classList.remove('rp-admin-layout--sidebar-collapsed');
33
+ }
34
+ localStorage.setItem(STORAGE_KEY, collapsed ? 'true' : 'false');
35
+ }
36
+
37
+ // Restore state on load
38
+ if (isCollapsed()) {
39
+ setCollapsed(true);
40
+ }
41
+
42
+ toggle.addEventListener('click', function() {
43
+ setCollapsed(!sidebar.classList.contains('rp-sidebar--collapsed'));
44
+ });
45
+ }
46
+
9
47
  // ============================================
10
48
  // Mobile Menu Toggle
11
49
  // ============================================
@@ -194,6 +232,7 @@
194
232
  // ============================================
195
233
 
196
234
  function init() {
235
+ initSidebarCollapse();
197
236
  initMobileMenu();
198
237
  initSlugGenerator();
199
238
  initDeleteConfirmation();
@@ -0,0 +1,343 @@
1
+ /**
2
+ * RailsPress Markdown Mode
3
+ * Toggle between rich text (Lexxy) and raw markdown editing
4
+ */
5
+
6
+ (function() {
7
+ 'use strict';
8
+
9
+ // ============================================
10
+ // HTML to Markdown Converter (Simple)
11
+ // ============================================
12
+
13
+ function htmlToMarkdown(html) {
14
+ if (!html || html.trim() === '' || html === '<p></p>' || html === '<p><br></p>') {
15
+ return '';
16
+ }
17
+
18
+ let md = html;
19
+
20
+ // Normalize whitespace
21
+ md = md.replace(/\r\n/g, '\n');
22
+
23
+ // Convert block elements first
24
+
25
+ // Headings
26
+ md = md.replace(/<h1[^>]*>(.*?)<\/h1>/gi, '# $1\n\n');
27
+ md = md.replace(/<h2[^>]*>(.*?)<\/h2>/gi, '## $1\n\n');
28
+ md = md.replace(/<h3[^>]*>(.*?)<\/h3>/gi, '### $1\n\n');
29
+ md = md.replace(/<h4[^>]*>(.*?)<\/h4>/gi, '#### $1\n\n');
30
+ md = md.replace(/<h5[^>]*>(.*?)<\/h5>/gi, '##### $1\n\n');
31
+ md = md.replace(/<h6[^>]*>(.*?)<\/h6>/gi, '###### $1\n\n');
32
+
33
+ // Code blocks (before inline code)
34
+ md = md.replace(/<pre[^>]*><code[^>]*class="[^"]*language-(\w+)[^"]*"[^>]*>([\s\S]*?)<\/code><\/pre>/gi, function(match, lang, code) {
35
+ return '```' + lang + '\n' + decodeHtmlEntities(code.trim()) + '\n```\n\n';
36
+ });
37
+ md = md.replace(/<pre[^>]*><code[^>]*>([\s\S]*?)<\/code><\/pre>/gi, function(match, code) {
38
+ return '```\n' + decodeHtmlEntities(code.trim()) + '\n```\n\n';
39
+ });
40
+ md = md.replace(/<pre[^>]*>([\s\S]*?)<\/pre>/gi, function(match, code) {
41
+ return '```\n' + decodeHtmlEntities(code.trim()) + '\n```\n\n';
42
+ });
43
+
44
+ // Blockquotes
45
+ md = md.replace(/<blockquote[^>]*>([\s\S]*?)<\/blockquote>/gi, function(match, content) {
46
+ const lines = content.replace(/<\/?p[^>]*>/gi, '\n').trim().split('\n');
47
+ return lines.map(line => '> ' + line.trim()).join('\n') + '\n\n';
48
+ });
49
+
50
+ // Horizontal rules
51
+ md = md.replace(/<hr[^>]*>/gi, '---\n\n');
52
+
53
+ // Lists
54
+ md = md.replace(/<ul[^>]*>([\s\S]*?)<\/ul>/gi, function(match, content) {
55
+ return content.replace(/<li[^>]*>([\s\S]*?)<\/li>/gi, '- $1\n') + '\n';
56
+ });
57
+ md = md.replace(/<ol[^>]*>([\s\S]*?)<\/ol>/gi, function(match, content) {
58
+ let index = 1;
59
+ return content.replace(/<li[^>]*>([\s\S]*?)<\/li>/gi, function(m, item) {
60
+ return (index++) + '. ' + item.trim() + '\n';
61
+ }) + '\n';
62
+ });
63
+
64
+ // Paragraphs
65
+ md = md.replace(/<p[^>]*>([\s\S]*?)<\/p>/gi, '$1\n\n');
66
+
67
+ // Line breaks
68
+ md = md.replace(/<br\s*\/?>/gi, ' \n');
69
+
70
+ // Now inline elements
71
+
72
+ // Images (before links to avoid nested issues)
73
+ md = md.replace(/<img[^>]*src="([^"]*)"[^>]*alt="([^"]*)"[^>]*>/gi, '![$2]($1)');
74
+ md = md.replace(/<img[^>]*alt="([^"]*)"[^>]*src="([^"]*)"[^>]*>/gi, '![$1]($2)');
75
+ md = md.replace(/<img[^>]*src="([^"]*)"[^>]*>/gi, '![]($1)');
76
+
77
+ // Links
78
+ md = md.replace(/<a[^>]*href="([^"]*)"[^>]*>([\s\S]*?)<\/a>/gi, '[$2]($1)');
79
+
80
+ // Bold and italic (order matters)
81
+ md = md.replace(/<(strong|b)[^>]*>([\s\S]*?)<\/\1>/gi, '**$2**');
82
+ md = md.replace(/<(em|i)[^>]*>([\s\S]*?)<\/\1>/gi, '*$2*');
83
+
84
+ // Inline code
85
+ md = md.replace(/<code[^>]*>(.*?)<\/code>/gi, '`$1`');
86
+
87
+ // Strikethrough
88
+ md = md.replace(/<(del|s|strike)[^>]*>([\s\S]*?)<\/\1>/gi, '~~$2~~');
89
+
90
+ // Remove remaining tags
91
+ md = md.replace(/<[^>]+>/g, '');
92
+
93
+ // Decode HTML entities
94
+ md = decodeHtmlEntities(md);
95
+
96
+ // Clean up whitespace
97
+ md = md.replace(/\n{3,}/g, '\n\n');
98
+ md = md.trim();
99
+
100
+ return md;
101
+ }
102
+
103
+ // ============================================
104
+ // Markdown to HTML Converter (Simple)
105
+ // ============================================
106
+
107
+ function markdownToHtml(md) {
108
+ if (!md || md.trim() === '') {
109
+ return '<p></p>';
110
+ }
111
+
112
+ let html = md;
113
+
114
+ // Escape HTML entities in the source
115
+ html = html.replace(/&/g, '&amp;');
116
+ html = html.replace(/</g, '&lt;');
117
+ html = html.replace(/>/g, '&gt;');
118
+
119
+ // Code blocks first (to protect their contents)
120
+ const codeBlocks = [];
121
+ html = html.replace(/```(\w*)\n([\s\S]*?)```/g, function(match, lang, code) {
122
+ const index = codeBlocks.length;
123
+ const langClass = lang ? ` class="language-${lang}"` : '';
124
+ codeBlocks.push(`<pre><code${langClass}>${code.trim()}</code></pre>`);
125
+ return `%%CODEBLOCK${index}%%`;
126
+ });
127
+
128
+ // Inline code (protect before other processing)
129
+ const inlineCodes = [];
130
+ html = html.replace(/`([^`]+)`/g, function(match, code) {
131
+ const index = inlineCodes.length;
132
+ inlineCodes.push(`<code>${code}</code>`);
133
+ return `%%INLINECODE${index}%%`;
134
+ });
135
+
136
+ // Headings
137
+ html = html.replace(/^###### (.*)$/gm, '<h6>$1</h6>');
138
+ html = html.replace(/^##### (.*)$/gm, '<h5>$1</h5>');
139
+ html = html.replace(/^#### (.*)$/gm, '<h4>$1</h4>');
140
+ html = html.replace(/^### (.*)$/gm, '<h3>$1</h3>');
141
+ html = html.replace(/^## (.*)$/gm, '<h2>$1</h2>');
142
+ html = html.replace(/^# (.*)$/gm, '<h1>$1</h1>');
143
+
144
+ // Horizontal rules
145
+ html = html.replace(/^---$/gm, '<hr>');
146
+ html = html.replace(/^\*\*\*$/gm, '<hr>');
147
+ html = html.replace(/^___$/gm, '<hr>');
148
+
149
+ // Blockquotes
150
+ html = html.replace(/^> (.*)$/gm, '<blockquote>$1</blockquote>');
151
+ // Merge consecutive blockquotes
152
+ html = html.replace(/<\/blockquote>\n<blockquote>/g, '\n');
153
+
154
+ // Unordered lists
155
+ html = html.replace(/^[\*\-] (.*)$/gm, '<li>$1</li>');
156
+ html = html.replace(/(<li>.*<\/li>\n?)+/g, function(match) {
157
+ return '<ul>\n' + match + '</ul>\n';
158
+ });
159
+
160
+ // Ordered lists
161
+ html = html.replace(/^\d+\. (.*)$/gm, '<oli>$1</oli>');
162
+ html = html.replace(/(<oli>.*<\/oli>\n?)+/g, function(match) {
163
+ return '<ol>\n' + match.replace(/oli>/g, 'li>') + '</ol>\n';
164
+ });
165
+
166
+ // Images
167
+ html = html.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, '<img src="$2" alt="$1">');
168
+
169
+ // Links
170
+ html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2">$1</a>');
171
+
172
+ // Bold and italic
173
+ html = html.replace(/\*\*\*([^*]+)\*\*\*/g, '<strong><em>$1</em></strong>');
174
+ html = html.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
175
+ html = html.replace(/\*([^*]+)\*/g, '<em>$1</em>');
176
+ html = html.replace(/__([^_]+)__/g, '<strong>$1</strong>');
177
+ html = html.replace(/_([^_]+)_/g, '<em>$1</em>');
178
+
179
+ // Strikethrough
180
+ html = html.replace(/~~([^~]+)~~/g, '<del>$1</del>');
181
+
182
+ // Line breaks (two spaces at end of line)
183
+ html = html.replace(/ \n/g, '<br>\n');
184
+
185
+ // Paragraphs - wrap remaining text blocks
186
+ html = html.split(/\n\n+/).map(function(block) {
187
+ block = block.trim();
188
+ if (!block) return '';
189
+ // Don't wrap if already a block element
190
+ if (/^<(h[1-6]|ul|ol|blockquote|pre|hr|div|p)/i.test(block)) {
191
+ return block;
192
+ }
193
+ // Don't wrap code block placeholders
194
+ if (/^%%CODEBLOCK\d+%%$/.test(block)) {
195
+ return block;
196
+ }
197
+ return '<p>' + block.replace(/\n/g, '<br>') + '</p>';
198
+ }).join('\n');
199
+
200
+ // Restore code blocks
201
+ codeBlocks.forEach(function(code, index) {
202
+ html = html.replace(`%%CODEBLOCK${index}%%`, code);
203
+ });
204
+
205
+ // Restore inline code
206
+ inlineCodes.forEach(function(code, index) {
207
+ html = html.replace(`%%INLINECODE${index}%%`, code);
208
+ });
209
+
210
+ return html;
211
+ }
212
+
213
+ // ============================================
214
+ // Helper Functions
215
+ // ============================================
216
+
217
+ function decodeHtmlEntities(text) {
218
+ const textarea = document.createElement('textarea');
219
+ textarea.innerHTML = text;
220
+ return textarea.value;
221
+ }
222
+
223
+ // ============================================
224
+ // Markdown Mode Controller
225
+ // ============================================
226
+
227
+ function initMarkdownMode() {
228
+ console.log('[MarkdownMode] Initializing...');
229
+ const containers = document.querySelectorAll('[data-markdown-mode]');
230
+ console.log('[MarkdownMode] Found containers:', containers.length);
231
+
232
+ containers.forEach(function(container, index) {
233
+ console.log('[MarkdownMode] Container', index, ':', container);
234
+ const editor = container.querySelector('lexxy-editor');
235
+ const textarea = container.querySelector('[data-markdown-textarea]');
236
+ const toggleBtn = container.querySelector('[data-markdown-toggle]');
237
+ const richLabel = container.querySelector('[data-mode-label="rich"]');
238
+ const mdLabel = container.querySelector('[data-mode-label="markdown"]');
239
+
240
+ console.log('[MarkdownMode] Container', index, 'elements:', {
241
+ editor: !!editor,
242
+ textarea: !!textarea,
243
+ toggleBtn: !!toggleBtn
244
+ });
245
+
246
+ if (!editor || !textarea || !toggleBtn) {
247
+ console.log('[MarkdownMode] Container', index, 'missing required elements, skipping');
248
+ return;
249
+ }
250
+ console.log('[MarkdownMode] Container', index, 'initialized successfully');
251
+
252
+ let isMarkdownMode = false;
253
+
254
+ function updateLabels() {
255
+ if (richLabel) richLabel.hidden = isMarkdownMode;
256
+ if (mdLabel) mdLabel.hidden = !isMarkdownMode;
257
+ }
258
+
259
+ function switchToMarkdown() {
260
+ // Get HTML from Lexxy
261
+ const html = editor.value || '';
262
+
263
+ // Convert to markdown
264
+ const markdown = htmlToMarkdown(html);
265
+
266
+ // Show textarea, hide editor
267
+ textarea.value = markdown;
268
+ textarea.hidden = false;
269
+ editor.style.display = 'none';
270
+
271
+ isMarkdownMode = true;
272
+ toggleBtn.setAttribute('aria-pressed', 'true');
273
+ updateLabels();
274
+ }
275
+
276
+ function switchToRichText() {
277
+ // Get markdown from textarea
278
+ const markdown = textarea.value || '';
279
+
280
+ // Convert to HTML
281
+ const html = markdownToHtml(markdown);
282
+
283
+ // Show editor, hide textarea
284
+ editor.style.display = '';
285
+ textarea.hidden = true;
286
+
287
+ // Load HTML into Lexxy
288
+ editor.value = html;
289
+
290
+ isMarkdownMode = false;
291
+ toggleBtn.setAttribute('aria-pressed', 'false');
292
+ updateLabels();
293
+ }
294
+
295
+ toggleBtn.addEventListener('click', function() {
296
+ console.log('[MarkdownMode] Toggle clicked, current mode:', isMarkdownMode ? 'markdown' : 'rich');
297
+ if (isMarkdownMode) {
298
+ switchToRichText();
299
+ } else {
300
+ switchToMarkdown();
301
+ }
302
+ });
303
+
304
+ // Sync textarea changes back to hidden input on form submit
305
+ const form = container.closest('form');
306
+ if (form) {
307
+ form.addEventListener('submit', function() {
308
+ if (isMarkdownMode) {
309
+ // Convert markdown to HTML before submit
310
+ const markdown = textarea.value || '';
311
+ const html = markdownToHtml(markdown);
312
+ editor.value = html;
313
+ }
314
+ });
315
+ }
316
+
317
+ // Initialize labels
318
+ updateLabels();
319
+ });
320
+ }
321
+
322
+ // ============================================
323
+ // Export for testing/external use
324
+ // ============================================
325
+
326
+ window.RailsPress = window.RailsPress || {};
327
+ window.RailsPress.MarkdownMode = {
328
+ htmlToMarkdown: htmlToMarkdown,
329
+ markdownToHtml: markdownToHtml,
330
+ init: initMarkdownMode
331
+ };
332
+
333
+ // ============================================
334
+ // Initialize
335
+ // ============================================
336
+
337
+ if (document.readyState === 'loading') {
338
+ document.addEventListener('DOMContentLoaded', initMarkdownMode);
339
+ } else {
340
+ initMarkdownMode();
341
+ }
342
+
343
+ })();