open_blog 0.1.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 (257) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +52 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +101 -0
  5. data/app/assets/builds/open_blog/blog.css +2 -0
  6. data/app/assets/javascripts/open_blog/controllers/code_copy_controller.js +40 -0
  7. data/app/assets/javascripts/open_blog/controllers/reading_progress_controller.js +36 -0
  8. data/app/assets/javascripts/open_blog/controllers/search_controller.js +49 -0
  9. data/app/assets/javascripts/open_blog/controllers/share_controller.js +48 -0
  10. data/app/assets/javascripts/open_blog/controllers/theme_controller.js +51 -0
  11. data/app/assets/javascripts/open_blog/controllers/toc_controller.js +33 -0
  12. data/app/controllers/concerns/open_blog/reader_controller.rb +49 -0
  13. data/app/controllers/open_blog/api/v1/adoptions_controller.rb +27 -0
  14. data/app/controllers/open_blog/api/v1/approvals_controller.rb +15 -0
  15. data/app/controllers/open_blog/api/v1/authors_controller.rb +62 -0
  16. data/app/controllers/open_blog/api/v1/base_controller.rb +100 -0
  17. data/app/controllers/open_blog/api/v1/categories_controller.rb +33 -0
  18. data/app/controllers/open_blog/api/v1/connections_controller.rb +45 -0
  19. data/app/controllers/open_blog/api/v1/doctor_controller.rb +13 -0
  20. data/app/controllers/open_blog/api/v1/faq_extractions_controller.rb +13 -0
  21. data/app/controllers/open_blog/api/v1/findings_controller.rb +13 -0
  22. data/app/controllers/open_blog/api/v1/images_controller.rb +22 -0
  23. data/app/controllers/open_blog/api/v1/pages_controller.rb +67 -0
  24. data/app/controllers/open_blog/api/v1/posts_controller.rb +114 -0
  25. data/app/controllers/open_blog/api/v1/previews_controller.rb +13 -0
  26. data/app/controllers/open_blog/api/v1/records_controller.rb +13 -0
  27. data/app/controllers/open_blog/api/v1/redirects_controller.rb +51 -0
  28. data/app/controllers/open_blog/api/v1/report_controller.rb +20 -0
  29. data/app/controllers/open_blog/api/v1/resources_controller.rb +39 -0
  30. data/app/controllers/open_blog/api/v1/series_controller.rb +32 -0
  31. data/app/controllers/open_blog/api/v1/tags_controller.rb +12 -0
  32. data/app/controllers/open_blog/api/v1/views_controller.rb +19 -0
  33. data/app/controllers/open_blog/application_controller.rb +5 -0
  34. data/app/controllers/open_blog/authors_controller.rb +14 -0
  35. data/app/controllers/open_blog/categories_controller.rb +15 -0
  36. data/app/controllers/open_blog/errors_controller.rb +7 -0
  37. data/app/controllers/open_blog/feeds_controller.rb +22 -0
  38. data/app/controllers/open_blog/mcp_controller.rb +38 -0
  39. data/app/controllers/open_blog/media_controller.rb +20 -0
  40. data/app/controllers/open_blog/pages_controller.rb +12 -0
  41. data/app/controllers/open_blog/posts_controller.rb +57 -0
  42. data/app/controllers/open_blog/previews_controller.rb +35 -0
  43. data/app/controllers/open_blog/search_controller.rb +32 -0
  44. data/app/controllers/open_blog/series_controller.rb +15 -0
  45. data/app/controllers/open_blog/sitemaps_controller.rb +10 -0
  46. data/app/controllers/open_blog/tags_controller.rb +15 -0
  47. data/app/helpers/open_blog/content_helper.rb +79 -0
  48. data/app/helpers/open_blog/dates_helper.rb +18 -0
  49. data/app/helpers/open_blog/faq_helper.rb +59 -0
  50. data/app/helpers/open_blog/head_helper.rb +75 -0
  51. data/app/helpers/open_blog/icons_helper.rb +10 -0
  52. data/app/helpers/open_blog/notices_helper.rb +47 -0
  53. data/app/helpers/open_blog/pagination_helper.rb +18 -0
  54. data/app/helpers/open_blog/structured_data_helper.rb +63 -0
  55. data/app/helpers/open_blog/theme_helper.rb +52 -0
  56. data/app/helpers/open_blog/url_helper.rb +58 -0
  57. data/app/jobs/open_blog/publish_scheduled_post_job.rb +14 -0
  58. data/app/models/concerns/open_blog/immutable.rb +13 -0
  59. data/app/models/concerns/open_blog/immutable_image_attachment.rb +43 -0
  60. data/app/models/concerns/open_blog/previewable.rb +38 -0
  61. data/app/models/open_blog/api_token.rb +34 -0
  62. data/app/models/open_blog/application_record.rb +5 -0
  63. data/app/models/open_blog/approval.rb +19 -0
  64. data/app/models/open_blog/author.rb +11 -0
  65. data/app/models/open_blog/baseline.rb +26 -0
  66. data/app/models/open_blog/category.rb +10 -0
  67. data/app/models/open_blog/connection_declaration.rb +16 -0
  68. data/app/models/open_blog/faq.rb +22 -0
  69. data/app/models/open_blog/image.rb +45 -0
  70. data/app/models/open_blog/page.rb +23 -0
  71. data/app/models/open_blog/page_view.rb +7 -0
  72. data/app/models/open_blog/post.rb +132 -0
  73. data/app/models/open_blog/publication.rb +17 -0
  74. data/app/models/open_blog/redirect.rb +16 -0
  75. data/app/models/open_blog/revision.rb +12 -0
  76. data/app/models/open_blog/series.rb +10 -0
  77. data/app/models/open_blog/tag.rb +11 -0
  78. data/app/models/open_blog/tagging.rb +7 -0
  79. data/app/queries/open_blog/api_post_query.rb +54 -0
  80. data/app/serializers/open_blog/adoption_result_serializer.rb +9 -0
  81. data/app/serializers/open_blog/author_serializer.rb +10 -0
  82. data/app/serializers/open_blog/category_serializer.rb +7 -0
  83. data/app/serializers/open_blog/doctor_serializer.rb +7 -0
  84. data/app/serializers/open_blog/error_serializer.rb +7 -0
  85. data/app/serializers/open_blog/extraction_result_serializer.rb +7 -0
  86. data/app/serializers/open_blog/findings_serializer.rb +13 -0
  87. data/app/serializers/open_blog/image_serializer.rb +9 -0
  88. data/app/serializers/open_blog/page_serializer.rb +9 -0
  89. data/app/serializers/open_blog/post_serializer.rb +59 -0
  90. data/app/serializers/open_blog/preview_serializer.rb +11 -0
  91. data/app/serializers/open_blog/records_serializer.rb +28 -0
  92. data/app/serializers/open_blog/redirect_serializer.rb +7 -0
  93. data/app/serializers/open_blog/series_serializer.rb +7 -0
  94. data/app/serializers/open_blog/tag_serializer.rb +7 -0
  95. data/app/serializers/open_blog/write_result_serializer.rb +9 -0
  96. data/app/views/open_blog/attachments/_blob.html.erb +1 -0
  97. data/app/views/open_blog/feeds/atom.xml.builder +27 -0
  98. data/app/views/open_blog/sitemaps/show.xml.builder +9 -0
  99. data/config/locales/open_blog.en.yml +95 -0
  100. data/config/routes.rb +52 -0
  101. data/db/migrate/20261003012512_create_open_blog_authors.rb +16 -0
  102. data/db/migrate/20261003012513_create_open_blog_categories.rb +13 -0
  103. data/db/migrate/20261003012514_create_open_blog_series.rb +11 -0
  104. data/db/migrate/20261003012515_create_open_blog_images.rb +17 -0
  105. data/db/migrate/20261003012516_create_open_blog_posts.rb +47 -0
  106. data/db/migrate/20261003012517_create_open_blog_faqs.rb +13 -0
  107. data/db/migrate/20261003012518_create_open_blog_tags.rb +17 -0
  108. data/db/migrate/20261003012519_create_open_blog_redirects.rb +14 -0
  109. data/db/migrate/20261003012520_create_open_blog_revisions.rb +14 -0
  110. data/db/migrate/20261003012521_create_open_blog_approvals.rb +21 -0
  111. data/db/migrate/20261003012522_create_open_blog_publications.rb +18 -0
  112. data/db/migrate/20261003012523_create_open_blog_baselines.rb +24 -0
  113. data/db/migrate/20261003012524_create_open_blog_connection_declarations.rb +13 -0
  114. data/db/migrate/20261003031410_add_body_image_manifest_to_open_blog_posts.rb +7 -0
  115. data/db/migrate/20261003164418_create_open_blog_api_tokens.rb +15 -0
  116. data/db/migrate/20261003175454_create_open_blog_pages.rb +16 -0
  117. data/db/migrate/20261003182919_create_open_blog_page_views.rb +11 -0
  118. data/lib/generators/open_blog/admin_suite/admin_suite_generator.rb +27 -0
  119. data/lib/generators/open_blog/admin_suite/templates/author_resource.rb.tt +29 -0
  120. data/lib/generators/open_blog/admin_suite/templates/blog_portal.rb.tt +16 -0
  121. data/lib/generators/open_blog/admin_suite/templates/category_resource.rb.tt +28 -0
  122. data/lib/generators/open_blog/admin_suite/templates/initializer.rb.tt +19 -0
  123. data/lib/generators/open_blog/admin_suite/templates/page_resource.rb.tt +32 -0
  124. data/lib/generators/open_blog/admin_suite/templates/post_resource.rb.tt +58 -0
  125. data/lib/generators/open_blog/generator_support.rb +64 -0
  126. data/lib/generators/open_blog/install/install_generator.rb +188 -0
  127. data/lib/generators/open_blog/install/templates/initializer.rb.tt +16 -0
  128. data/lib/generators/open_blog/install/templates/sample_cover.png +0 -0
  129. data/lib/generators/open_blog/install/templates/sample_post.md +54 -0
  130. data/lib/generators/open_blog/install/templates/theme/blog.css +193 -0
  131. data/lib/generators/open_blog/install/templates/theme/open_blog_theme.css +82 -0
  132. data/lib/generators/open_blog/install/templates/theme/syntax.css +358 -0
  133. data/lib/generators/open_blog/install/templates/theme/theme.css +120 -0
  134. data/lib/generators/open_blog/install/templates/views/layouts/open_blog.html.erb +16 -0
  135. data/lib/generators/open_blog/install/templates/views/open_blog/authors/show.html.erb +17 -0
  136. data/lib/generators/open_blog/install/templates/views/open_blog/categories/show.html.erb +13 -0
  137. data/lib/generators/open_blog/install/templates/views/open_blog/errors/not_found.html.erb +11 -0
  138. data/lib/generators/open_blog/install/templates/views/open_blog/errors/preview_expired.html.erb +5 -0
  139. data/lib/generators/open_blog/install/templates/views/open_blog/icons/_check.svg.erb +1 -0
  140. data/lib/generators/open_blog/install/templates/views/open_blog/icons/_copy.svg.erb +1 -0
  141. data/lib/generators/open_blog/install/templates/views/open_blog/icons/_link.svg.erb +1 -0
  142. data/lib/generators/open_blog/install/templates/views/open_blog/icons/_monitor.svg.erb +1 -0
  143. data/lib/generators/open_blog/install/templates/views/open_blog/icons/_moon.svg.erb +1 -0
  144. data/lib/generators/open_blog/install/templates/views/open_blog/icons/_share.svg.erb +1 -0
  145. data/lib/generators/open_blog/install/templates/views/open_blog/icons/_sun.svg.erb +1 -0
  146. data/lib/generators/open_blog/install/templates/views/open_blog/pages/show.html.erb +5 -0
  147. data/lib/generators/open_blog/install/templates/views/open_blog/posts/_faq.html.erb +1 -0
  148. data/lib/generators/open_blog/install/templates/views/open_blog/posts/_highlights.html.erb +7 -0
  149. data/lib/generators/open_blog/install/templates/views/open_blog/posts/_preview_bar.html.erb +1 -0
  150. data/lib/generators/open_blog/install/templates/views/open_blog/posts/_series_nav.html.erb +12 -0
  151. data/lib/generators/open_blog/install/templates/views/open_blog/posts/_tags.html.erb +5 -0
  152. data/lib/generators/open_blog/install/templates/views/open_blog/posts/_toc.html.erb +1 -0
  153. data/lib/generators/open_blog/install/templates/views/open_blog/posts/index.html.erb +13 -0
  154. data/lib/generators/open_blog/install/templates/views/open_blog/posts/show.html.erb +29 -0
  155. data/lib/generators/open_blog/install/templates/views/open_blog/search/show.html.erb +13 -0
  156. data/lib/generators/open_blog/install/templates/views/open_blog/series/show.html.erb +13 -0
  157. data/lib/generators/open_blog/install/templates/views/open_blog/shared/_author_box.html.erb +6 -0
  158. data/lib/generators/open_blog/install/templates/views/open_blog/shared/_breadcrumbs.html.erb +1 -0
  159. data/lib/generators/open_blog/install/templates/views/open_blog/shared/_call_to_action.html.erb +7 -0
  160. data/lib/generators/open_blog/install/templates/views/open_blog/shared/_featured_card.html.erb +11 -0
  161. data/lib/generators/open_blog/install/templates/views/open_blog/shared/_footer.html.erb +11 -0
  162. data/lib/generators/open_blog/install/templates/views/open_blog/shared/_header.html.erb +7 -0
  163. data/lib/generators/open_blog/install/templates/views/open_blog/shared/_pagination.html.erb +1 -0
  164. data/lib/generators/open_blog/install/templates/views/open_blog/shared/_post_card.html.erb +9 -0
  165. data/lib/generators/open_blog/install/templates/views/open_blog/shared/_related_posts.html.erb +10 -0
  166. data/lib/generators/open_blog/install/templates/views/open_blog/shared/_search.html.erb +9 -0
  167. data/lib/generators/open_blog/install/templates/views/open_blog/shared/_share.html.erb +13 -0
  168. data/lib/generators/open_blog/install/templates/views/open_blog/shared/_sidebar.html.erb +38 -0
  169. data/lib/generators/open_blog/install/templates/views/open_blog/shared/_theme_toggle.html.erb +10 -0
  170. data/lib/generators/open_blog/install/templates/views/open_blog/tags/show.html.erb +13 -0
  171. data/lib/generators/open_blog/views/views_generator.rb +21 -0
  172. data/lib/open_blog/actor.rb +7 -0
  173. data/lib/open_blog/admin_suite.rb +73 -0
  174. data/lib/open_blog/adopt/contract.rb +152 -0
  175. data/lib/open_blog/adopt/snapshot.rb +35 -0
  176. data/lib/open_blog/adopt.rb +159 -0
  177. data/lib/open_blog/api_fields.rb +40 -0
  178. data/lib/open_blog/approve.rb +18 -0
  179. data/lib/open_blog/authentication.rb +18 -0
  180. data/lib/open_blog/build_css.rb +53 -0
  181. data/lib/open_blog/configuration.rb +185 -0
  182. data/lib/open_blog/configuration_error.rb +4 -0
  183. data/lib/open_blog/content_guard.rb +77 -0
  184. data/lib/open_blog/doctor/files.rb +61 -0
  185. data/lib/open_blog/doctor/javascript.rb +57 -0
  186. data/lib/open_blog/doctor.rb +177 -0
  187. data/lib/open_blog/engine.rb +45 -0
  188. data/lib/open_blog/errors.rb +40 -0
  189. data/lib/open_blog/faq_extraction.rb +184 -0
  190. data/lib/open_blog/findings/body_checks.rb +58 -0
  191. data/lib/open_blog/findings/check.rb +19 -0
  192. data/lib/open_blog/findings/link_check.rb +78 -0
  193. data/lib/open_blog/findings/record_checks.rb +94 -0
  194. data/lib/open_blog/findings/registry.rb +18 -0
  195. data/lib/open_blog/findings.rb +27 -0
  196. data/lib/open_blog/image_fetch.rb +115 -0
  197. data/lib/open_blog/image_import.rb +164 -0
  198. data/lib/open_blog/image_resolution.rb +171 -0
  199. data/lib/open_blog/instructions.rb +7 -0
  200. data/lib/open_blog/json_feed.rb +36 -0
  201. data/lib/open_blog/label_policy.rb +41 -0
  202. data/lib/open_blog/markdown_view.rb +57 -0
  203. data/lib/open_blog/mcp/definition.rb +28 -0
  204. data/lib/open_blog/mcp/dispatcher.rb +33 -0
  205. data/lib/open_blog/mcp/image_input.rb +30 -0
  206. data/lib/open_blog/mcp/registry.rb +139 -0
  207. data/lib/open_blog/mcp/schemas.rb +72 -0
  208. data/lib/open_blog/mcp.rb +24 -0
  209. data/lib/open_blog/not_found.rb +4 -0
  210. data/lib/open_blog/operation.rb +36 -0
  211. data/lib/open_blog/page_views.rb +72 -0
  212. data/lib/open_blog/pagination.rb +18 -0
  213. data/lib/open_blog/plain_text.rb +36 -0
  214. data/lib/open_blog/policy_url.rb +9 -0
  215. data/lib/open_blog/post_attributes.rb +172 -0
  216. data/lib/open_blog/post_identity.rb +28 -0
  217. data/lib/open_blog/publish.rb +7 -0
  218. data/lib/open_blog/reader_dates.rb +21 -0
  219. data/lib/open_blog/reader_page.rb +21 -0
  220. data/lib/open_blog/reader_queries.rb +90 -0
  221. data/lib/open_blog/record_release.rb +66 -0
  222. data/lib/open_blog/redirect_target.rb +58 -0
  223. data/lib/open_blog/remove.rb +43 -0
  224. data/lib/open_blog/renderer/highlighter.rb +44 -0
  225. data/lib/open_blog/renderer/markdown.rb +14 -0
  226. data/lib/open_blog/renderer/rich_text.rb +19 -0
  227. data/lib/open_blog/renderer/sanitizer.rb +72 -0
  228. data/lib/open_blog/renderer/scrubber.rb +35 -0
  229. data/lib/open_blog/renderer/transform.rb +115 -0
  230. data/lib/open_blog/renderer.rb +51 -0
  231. data/lib/open_blog/result.rb +11 -0
  232. data/lib/open_blog/revision_payload.rb +67 -0
  233. data/lib/open_blog/rich_text_guard.rb +26 -0
  234. data/lib/open_blog/sample.rb +56 -0
  235. data/lib/open_blog/save_draft.rb +7 -0
  236. data/lib/open_blog/search.rb +21 -0
  237. data/lib/open_blog/sitemap_entries.rb +66 -0
  238. data/lib/open_blog/surface_report/expected_content.rb +27 -0
  239. data/lib/open_blog/surface_report/http.rb +92 -0
  240. data/lib/open_blog/surface_report/page_checks.rb +303 -0
  241. data/lib/open_blog/surface_report/reach_checks.rb +141 -0
  242. data/lib/open_blog/surface_report/record_checks.rb +248 -0
  243. data/lib/open_blog/surface_report/robots.rb +49 -0
  244. data/lib/open_blog/surface_report.rb +223 -0
  245. data/lib/open_blog/syntax_css.rb +55 -0
  246. data/lib/open_blog/unpublish.rb +13 -0
  247. data/lib/open_blog/version.rb +3 -0
  248. data/lib/open_blog/write_post.rb +205 -0
  249. data/lib/open_blog.rb +91 -0
  250. data/lib/tasks/open_blog.rake +71 -0
  251. data/skills/open-blog-adopt/SKILL.md +12 -0
  252. data/skills/open-blog-install/SKILL.md +10 -0
  253. data/skills/open-blog-policy-pages/SKILL.md +10 -0
  254. data/skills/open-blog-publish/SKILL.md +14 -0
  255. data/skills/open-blog-report/SKILL.md +10 -0
  256. data/skills/open-blog-update/SKILL.md +16 -0
  257. metadata +515 -0
@@ -0,0 +1,63 @@
1
+ module OpenBlog
2
+ module StructuredDataHelper
3
+ def open_blog_structured_data(resource = nil)
4
+ page = open_blog_page(resource)
5
+ graph = [ open_blog_page_schema(page), open_blog_breadcrumb_schema(page) ]
6
+ if %i[post preview].include?(page.kind) && (entries = page.record.faq_list).any?
7
+ graph << { "@type" => "FAQPage", "mainEntity" => entries.map do |entry|
8
+ { "@type" => "Question", "name" => entry[:question],
9
+ "acceptedAnswer" => { "@type" => "Answer", "text" => entry[:answer] } }
10
+ end }
11
+ end
12
+ json = ERB::Util.json_escape({ "@context" => "https://schema.org", "@graph" => graph }.to_json)
13
+ tag.script(json.html_safe, type: "application/ld+json")
14
+ end
15
+
16
+ def open_blog_page_schema(page)
17
+ case page.kind
18
+ when :post, :preview
19
+ post = page.record
20
+ publisher = OpenBlog.config.publisher || {}
21
+ data = { "@type" => "BlogPosting", "headline" => post.title, "description" => post.description.to_s,
22
+ "author" => open_blog_author_schema(post.author, name: post.author_name, base: page.base_url),
23
+ "publisher" => { "@type" => "Organization", "name" => publisher[:name], "url" => publisher[:url] }.compact,
24
+ "mainEntityOfPage" => open_blog_canonical_url(page), "wordCount" => post.word_count,
25
+ "keywords" => post.tags.map(&:name), "articleSection" => post.category&.name }
26
+ if publisher[:logo_url].present?
27
+ data["publisher"]["logo"] = { "@type" => "ImageObject", "url" => publisher[:logo_url] }
28
+ end
29
+ published, modified = ReaderDates.published_at(post), ReaderDates.modified_at(post)
30
+ data["datePublished"] = published.iso8601 if published
31
+ data["dateModified"] = modified.iso8601 if modified
32
+ image = open_blog_social_image_url(page)
33
+ data["image"] = image if image
34
+ data.compact
35
+ when :page
36
+ { "@type" => "WebPage", "name" => page.record.title, "description" => open_blog_page_description(page),
37
+ "url" => open_blog_canonical_url(page), "dateModified" => page.record.updated_at.iso8601 }
38
+ when :author
39
+ { "@type" => "ProfilePage", "name" => page.record.name, "url" => open_blog_canonical_url(page),
40
+ "mainEntity" => open_blog_author_schema(page.record, base: page.base_url) }
41
+ else
42
+ posts = page.pagination&.records || []
43
+ { "@type" => page.kind == :index ? "Blog" : "CollectionPage", "name" => open_blog_page_title(page),
44
+ "description" => open_blog_page_description(page), "url" => open_blog_canonical_url(page),
45
+ "mainEntity" => { "@type" => "ItemList", "itemListElement" => posts.each_with_index.map do |post, index|
46
+ { "@type" => "ListItem", "position" => index + 1, "url" => post.url(base: page.base_url), "name" => post.title }
47
+ end } }
48
+ end
49
+ end
50
+
51
+ def open_blog_author_schema(author, name: author.name, base: nil)
52
+ { "@type" => author.author_type == "organization" ? "Organization" : "Person", "name" => name,
53
+ "url" => open_blog_absolute_url(open_blog_list_path(:author, author), base: base) }
54
+ end
55
+
56
+ def open_blog_breadcrumb_schema(page)
57
+ { "@type" => "BreadcrumbList", "itemListElement" => page.breadcrumbs.each_with_index.map do |crumb, index|
58
+ { "@type" => "ListItem", "position" => index + 1, "name" => crumb[:name],
59
+ "item" => open_blog_absolute_url(crumb[:path], base: page.base_url) }
60
+ end }
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,52 @@
1
+ module OpenBlog
2
+ module ThemeHelper
3
+ SURFACES = { "light" => "#ffffff", "dark" => "#0f172a" }.freeze
4
+
5
+ def open_blog_fixed_theme
6
+ OpenBlog.config.color_scheme.to_s if %w[light dark].include?(OpenBlog.config.color_scheme.to_s)
7
+ end
8
+
9
+ def open_blog_theme_attributes
10
+ open_blog_fixed_theme ? { data: { theme: open_blog_fixed_theme } } : {}
11
+ end
12
+
13
+ def open_blog_theme_script
14
+ return "".html_safe if open_blog_fixed_theme
15
+ javascript_tag(nonce: content_security_policy_nonce, data: { open_blog_theme: true }) do
16
+ <<~JS.html_safe
17
+ (() => {
18
+ try {
19
+ const theme = localStorage.getItem("open-blog-theme");
20
+ if (theme === "light" || theme === "dark") document.documentElement.dataset.theme = theme;
21
+ } catch (_) {}
22
+ })();
23
+ JS
24
+ end
25
+ end
26
+
27
+ def open_blog_theme_colors
28
+ safe_join(SURFACES.map do |scheme, surface|
29
+ tag.meta(name: "theme-color", media: "(prefers-color-scheme: #{scheme})", content: SURFACES.fetch(open_blog_fixed_theme, surface))
30
+ end, "\n")
31
+ end
32
+
33
+ def open_blog_stylesheets
34
+ stylesheets = [ stylesheet_link_tag("open_blog/blog") ]
35
+ stylesheets << stylesheet_link_tag("open_blog_theme") if open_blog_theme_override?
36
+ safe_join(stylesheets, "\n")
37
+ end
38
+
39
+ private
40
+
41
+ def open_blog_theme_override?
42
+ assets = Rails.application.assets if Rails.application.respond_to?(:assets)
43
+ if assets.respond_to?(:resolver)
44
+ assets.resolver.resolve("open_blog_theme.css").present?
45
+ elsif assets.respond_to?(:find_asset)
46
+ assets.find_asset("open_blog_theme.css").present?
47
+ elsif Rails.application.respond_to?(:assets_manifest)
48
+ Rails.application.assets_manifest&.assets&.key?("open_blog_theme.css")
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,58 @@
1
+ require "uri"
2
+
3
+ module OpenBlog
4
+ module UrlHelper
5
+ def open_blog_index_path
6
+ OpenBlog.mount_path.chomp("/").presence || "/"
7
+ end
8
+
9
+ def open_blog_list_path(kind, record)
10
+ segment = OpenBlog.config.route_segments.fetch(kind.to_s.singularize.to_sym)
11
+ "#{open_blog_index_path.chomp('/')}/#{segment}/#{record.slug}"
12
+ end
13
+
14
+ def open_blog_page(resource = nil)
15
+ return resource if resource.is_a?(ReaderPage)
16
+ return @open_blog_page if @open_blog_page && (resource.nil? || resource == @open_blog_page.record)
17
+ kind = case resource
18
+ when Post then :post
19
+ when Page then :page
20
+ when Category then :category
21
+ when Tag then :tag
22
+ when Author then :author
23
+ when Series then :series
24
+ else :index
25
+ end
26
+ path = if %i[post page].include?(kind)
27
+ resource.path
28
+ elsif resource
29
+ open_blog_list_path(kind, resource)
30
+ else
31
+ open_blog_index_path
32
+ end
33
+ crumbs = [ { name: OpenBlog.config.blog_title, path: open_blog_index_path } ]
34
+ crumbs << { name: %i[post page].include?(kind) ? resource.title : resource.name, path: path } if resource
35
+ ReaderPage.new(kind: kind, record: resource, path: path, base_url: OpenBlog.config.public_base_url || request.base_url, breadcrumbs: crumbs)
36
+ end
37
+
38
+ def open_blog_absolute_url(path, base: nil)
39
+ return if path.blank?
40
+ base ||= @open_blog_page&.base_url || OpenBlog.config.public_base_url
41
+ base ||= request.base_url if respond_to?(:request) && request
42
+ uri = URI.join("#{base.to_s.chomp('/')}/", path.to_s)
43
+ uri.to_s if uri.is_a?(URI::HTTP) && uri.host.present? && uri.userinfo.nil?
44
+ rescue URI::InvalidURIError
45
+ nil
46
+ end
47
+
48
+ def open_blog_canonical_url(resource = nil)
49
+ page = open_blog_page(resource)
50
+ path = %i[post preview].include?(page.kind) && page.record.canonical_url.presence || page.canonical_path
51
+ open_blog_absolute_url(path, base: page.base_url)
52
+ end
53
+
54
+ def open_blog_translate(key, **options)
55
+ I18n.t("open_blog.#{key}", locale: OpenBlog.config.locale, **options)
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,14 @@
1
+ module OpenBlog
2
+ class PublishScheduledPostJob < ActiveJob::Base
3
+ def perform(post_id)
4
+ Post.transaction do
5
+ post = Post.lock.find_by(id: post_id)
6
+ now = Time.current
7
+ next unless post&.scheduled? && post.publish_at && post.publish_at <= now
8
+ result = WritePost.call({}, post: post, actor: nil, now: now, publish: :scheduled)
9
+ raise result.error unless result.success?
10
+ result
11
+ end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,13 @@
1
+ module OpenBlog
2
+ module Immutable
3
+ extend ActiveSupport::Concern
4
+
5
+ included do
6
+ before_destroy(prepend: true) { raise ActiveRecord::ReadOnlyRecord, "Stored records cannot be destroyed" if persisted? }
7
+ end
8
+
9
+ def readonly?
10
+ persisted? || super
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,43 @@
1
+ module OpenBlog
2
+ module ImmutableImageAttachment
3
+ extend ActiveSupport::Concern
4
+
5
+ STRUCTURAL_ATTRIBUTES = %w[name blob_id record_type record_id].freeze
6
+
7
+ included do
8
+ before_create :check_image_attachment_creation
9
+ before_update :check_image_attachment_changes
10
+ before_destroy :check_image_attachment_destruction, prepend: true
11
+ end
12
+
13
+ def update_columns(attributes)
14
+ if image_attachment?(attributes.stringify_keys["record_type"]) && (attributes.keys.map(&:to_s) & STRUCTURAL_ATTRIBUTES).any?
15
+ raise ActiveRecord::ReadOnlyRecord, "Stored image attachments cannot change"
16
+ end
17
+ super
18
+ end
19
+
20
+ private
21
+ def image_attachment?(target_type = nil)
22
+ [ record_type, record_type_in_database, target_type ].include?("OpenBlog::Image")
23
+ end
24
+
25
+ def check_image_attachment_creation
26
+ return unless image_attachment?
27
+ change = record&.attachment_changes&.fetch("file", nil)
28
+ unless record&.saved_change_to_id? && change&.attachment.equal?(self)
29
+ raise ActiveRecord::ReadOnlyRecord, "Image attachments must be created with their image"
30
+ end
31
+ end
32
+
33
+ def check_image_attachment_changes
34
+ if image_attachment? && (changes_to_save.keys & STRUCTURAL_ATTRIBUTES).any?
35
+ raise ActiveRecord::ReadOnlyRecord, "Stored image attachments cannot change"
36
+ end
37
+ end
38
+
39
+ def check_image_attachment_destruction
40
+ raise ActiveRecord::ReadOnlyRecord, "Stored image attachments cannot be destroyed" if image_attachment?
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,38 @@
1
+ module OpenBlog
2
+ module Previewable
3
+ extend ActiveSupport::Concern
4
+ TOKEN_LOCK = Mutex.new
5
+
6
+ class_methods do
7
+ def with_preview_tokens
8
+ TOKEN_LOCK.synchronize do
9
+ duration = OpenBlog.config.preview_duration
10
+ unless token_definitions[:preview]&.expires_in == duration
11
+ generates_token_for(:preview, expires_in: duration) { current_revision_identifier }
12
+ end
13
+ yield duration
14
+ end
15
+ end
16
+
17
+ def find_by_preview_token(token)
18
+ return unless token.is_a?(String)
19
+ with_preview_tokens { find_by_token_for(:preview, token) }
20
+ end
21
+ end
22
+
23
+ def previewable?
24
+ persisted? && (draft? || scheduled?)
25
+ end
26
+
27
+ def preview_token
28
+ preview_credentials.fetch(:token)
29
+ end
30
+
31
+ def preview_credentials
32
+ raise Error::NotFound unless previewable?
33
+ self.class.with_preview_tokens do |duration|
34
+ { token: generate_token_for(:preview), expires_at: Time.current + duration }
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,34 @@
1
+ require "digest"
2
+ require "active_support/core_ext/securerandom"
3
+
4
+ module OpenBlog
5
+ class ApiToken < ApplicationRecord
6
+ SCOPES = %w[read write publish].freeze
7
+ scope :active, -> { where(revoked_at: nil).where("expires_at IS NULL OR expires_at > ?", Time.current) }
8
+
9
+ validates :name, presence: true
10
+ validates :token_digest, presence: true, uniqueness: true, format: { with: /\A[0-9a-f]{64}\z/ }
11
+ validates :token_prefix, length: { is: 8 }
12
+ validate do
13
+ errors.add(:scopes, "must contain only read, write or publish") unless scopes.is_a?(Array) && (scopes - SCOPES).empty?
14
+ end
15
+
16
+ def self.generate(name:, scopes: SCOPES, expires_at: nil)
17
+ secret = "ob_#{SecureRandom.base58(40)}"
18
+ record = create!(name: name, scopes: scopes, expires_at: expires_at,
19
+ token_digest: Digest::SHA256.hexdigest(secret), token_prefix: secret[0, 8])
20
+ [ record, secret ]
21
+ end
22
+
23
+ def self.authenticate(secret)
24
+ return unless secret.is_a?(String) && secret.match?(/\Aob_[1-9A-HJ-NP-Za-km-z]{40}\z/)
25
+ digest = Digest::SHA256.hexdigest(secret)
26
+ record = active.find_by(token_digest: digest)
27
+ return unless record && ActiveSupport::SecurityUtils.secure_compare(record.token_digest, digest)
28
+ now = Time.current
29
+ return unless active.where(id: record.id).update_all(last_used_at: now) == 1
30
+ record.last_used_at = now
31
+ record
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,5 @@
1
+ module OpenBlog
2
+ class ApplicationRecord < ActiveRecord::Base
3
+ self.abstract_class = true
4
+ end
5
+ end
@@ -0,0 +1,19 @@
1
+ module OpenBlog
2
+ class Approval < ApplicationRecord
3
+ include Immutable
4
+ belongs_to :post
5
+ belongs_to :revision
6
+
7
+ validates :kind, inclusion: { in: %w[sent imported declared] }
8
+ validates :reviewer_name, :approved_at, presence: true
9
+ validates :facts_checked, inclusion: { in: [ true, false ] }
10
+ validates :declared_on, :declared_by, presence: true, if: -> { kind == "declared" }
11
+ validates :confirmed_by, :evidence, presence: true, if: -> { kind == "imported" }
12
+ validate :revision_belongs_to_post
13
+
14
+ private
15
+ def revision_belongs_to_post
16
+ errors.add(:revision, "must belong to the post") if revision && revision.post != post
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,11 @@
1
+ module OpenBlog
2
+ class Author < ApplicationRecord
3
+ has_many :posts, dependent: :restrict_with_exception
4
+ has_one_attached :avatar
5
+
6
+ before_validation { self.slug = name.to_s.parameterize if slug.blank? }
7
+ validates :name, :slug, presence: true
8
+ validates :slug, uniqueness: true, format: { with: /\A[a-z0-9]+(?:[-_][a-z0-9]+)*\z/ }
9
+ validates :author_type, inclusion: { in: %w[person organization] }
10
+ end
11
+ end
@@ -0,0 +1,26 @@
1
+ module OpenBlog
2
+ class Baseline < ApplicationRecord
3
+ include Immutable
4
+ belongs_to :post
5
+ belongs_to :adopted_revision, class_name: "OpenBlog::Revision"
6
+
7
+ validates :post_id, uniqueness: true
8
+ validates :adopted_at, presence: true
9
+ validates :provenance, inclusion: { in: %w[ai_assisted human_written unknown] }
10
+ validates :source_id, uniqueness: { scope: :source_system }, allow_nil: true
11
+ validates :source_body_sha256, format: { with: /\A[0-9a-f]{64}\z/ }, allow_nil: true
12
+ validate :separate_declared_and_evidenced_dates
13
+ validate :revision_belongs_to_post
14
+
15
+ private
16
+ def separate_declared_and_evidenced_dates
17
+ if first_published_at && declared_first_published_at
18
+ errors.add(:declared_first_published_at, "cannot accompany an evidenced first publication date")
19
+ end
20
+ end
21
+
22
+ def revision_belongs_to_post
23
+ errors.add(:adopted_revision, "must belong to the post") if adopted_revision && adopted_revision.post != post
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,10 @@
1
+ module OpenBlog
2
+ class Category < ApplicationRecord
3
+ has_many :posts, dependent: :nullify
4
+
5
+ before_validation { self.slug = name.to_s.parameterize if slug.blank? }
6
+ validates :name, :slug, presence: true, uniqueness: true
7
+ validates :slug, format: { with: /\A[a-z0-9]+(?:[-_][a-z0-9]+)*\z/ }
8
+ validates :position, numericality: { only_integer: true }
9
+ end
10
+ end
@@ -0,0 +1,16 @@
1
+ module OpenBlog
2
+ class ConnectionDeclaration < ApplicationRecord
3
+ include Immutable
4
+ belongs_to :post
5
+ validates :declared_by, :declared_on, presence: true
6
+ validates :third_party_paid, inclusion: { in: [ true, false ] }
7
+ validate :connections_are_named_relations
8
+
9
+ private
10
+ def connections_are_named_relations
11
+ unless connections.is_a?(Array) && connections.all? { |entry| entry.is_a?(Hash) && entry["party"].present? && entry["relation"].present? }
12
+ errors.add(:connections, "must be a list of named parties and relations")
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,22 @@
1
+ module OpenBlog
2
+ class Faq < ApplicationRecord
3
+ include ContentGuard
4
+ belongs_to :post, inverse_of: :faqs
5
+ validates :question, :answer, presence: true
6
+ validates :position, numericality: { only_integer: true, greater_than: 0 }, uniqueness: { scope: :post_id }
7
+
8
+ private
9
+
10
+ def content_guard_applies?
11
+ true
12
+ end
13
+
14
+ def content_guard_identity_changed?
15
+ persisted? && will_save_change_to_post_id?
16
+ end
17
+
18
+ def content_guard_parent
19
+ post
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,45 @@
1
+ module OpenBlog
2
+ class Image < ApplicationRecord
3
+ include Immutable
4
+ has_one_attached :file, service: ->(_) { OpenBlog.config.storage_service || Rails.application.config.active_storage.service }
5
+
6
+ validates :sha256, presence: true, uniqueness: true, format: { with: /\A[0-9a-f]{64}\z/ }
7
+ validates :filename, :content_type, presence: true
8
+ validates :byte_size, numericality: { only_integer: true, greater_than_or_equal_to: 0 }
9
+ validates :width, :height, numericality: { only_integer: true, greater_than: 0 }, allow_nil: true
10
+
11
+ module ImmutableFile
12
+ %i[attach detach purge purge_later].each do |operation|
13
+ define_method(operation) do |*arguments, **options|
14
+ raise ActiveRecord::ReadOnlyRecord, "Stored image files cannot change" if record.persisted?
15
+ super(*arguments, **options)
16
+ end
17
+ end
18
+ end
19
+
20
+ def self.from_upload(io, filename:, content_type:, uploaded_by: nil)
21
+ ImageImport.prepare_upload(io, filename: filename, content_type: content_type).persist(uploaded_by: uploaded_by)
22
+ end
23
+
24
+ def self.from_signed_id(signed_id, uploaded_by: nil)
25
+ ImageImport.prepare(signed_id: signed_id).persist(uploaded_by: uploaded_by)
26
+ end
27
+
28
+ def self.from_url(url, uploaded_by: nil)
29
+ ImageImport.prepare(url: url).persist(uploaded_by: uploaded_by)
30
+ end
31
+
32
+ def path
33
+ "#{OpenBlog.mount_path.chomp("/")}/media/#{sha256}/#{ERB::Util.url_encode(filename)}"
34
+ end
35
+
36
+ def file
37
+ super.extend(ImmutableFile)
38
+ end
39
+
40
+ def file=(attachable)
41
+ raise ActiveRecord::ReadOnlyRecord, "Stored image files cannot change" if persisted?
42
+ super
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,23 @@
1
+ module OpenBlog
2
+ class Page < ApplicationRecord
3
+ DEFAULT_SLUGS = { "responsible_party" => "responsible-party", "corrections" => "corrections",
4
+ "editorial" => "editorial-policy", "ai_use" => "ai-use" }.freeze
5
+ KINDS = DEFAULT_SLUGS.keys.freeze
6
+
7
+ enum :status, { draft: "draft", published: "published" }, validate: true
8
+ before_validation { self.slug = DEFAULT_SLUGS[kind] if slug.blank? }
9
+ validates :kind, inclusion: { in: KINDS }, uniqueness: true
10
+ validates :title, :slug, presence: true
11
+ validates :slug, uniqueness: true, format: { with: /\A[a-z0-9]+(?:[-_][a-z0-9]+)*\z/ }
12
+ validates :body_markdown, presence: true, if: :published?
13
+
14
+ def path
15
+ "#{OpenBlog.mount_path.chomp('/')}/policies/#{slug}"
16
+ end
17
+
18
+ def url(base: nil)
19
+ origin = base || OpenBlog.config.public_base_url
20
+ "#{origin.chomp('/')}#{path}" if origin
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,7 @@
1
+ module OpenBlog
2
+ class PageView < ApplicationRecord
3
+ belongs_to :post
4
+ validates :day, presence: true
5
+ validates :views, numericality: { only_integer: true, greater_than_or_equal_to: 0 }
6
+ end
7
+ end
@@ -0,0 +1,132 @@
1
+ require "uri"
2
+
3
+ module OpenBlog
4
+ class Post < ApplicationRecord
5
+ include Previewable
6
+ RESERVED_SLUGS = %w[feed search preview media policies api mcp sitemap].freeze
7
+ SLUG_PATTERN = /\A[a-z0-9]+(?:[-_][a-z0-9]+)*\z/
8
+
9
+ belongs_to :author
10
+ belongs_to :category, optional: true
11
+ belongs_to :series, optional: true
12
+ belongs_to :cover_image, class_name: "OpenBlog::Image", optional: true
13
+ belongs_to :social_image, class_name: "OpenBlog::Image", optional: true
14
+ belongs_to :public_revision, class_name: "OpenBlog::Revision", optional: true
15
+
16
+ has_many :faqs, -> { order(:position) }, autosave: true, dependent: :destroy, inverse_of: :post
17
+ accepts_nested_attributes_for :faqs, allow_destroy: true, reject_if: ->(attributes) { attributes["id"].blank? && attributes["question"].blank? && attributes["answer"].blank? }
18
+ has_many :taggings, autosave: true, dependent: :destroy
19
+ has_many :tags, through: :taggings, autosave: false
20
+ has_many :page_views, dependent: :delete_all
21
+ has_many :revisions, dependent: :restrict_with_exception
22
+ has_many :approvals, dependent: :restrict_with_exception
23
+ has_many :publications, dependent: :restrict_with_exception
24
+ has_many :connection_declarations, dependent: :restrict_with_exception
25
+ has_one :baseline, dependent: :restrict_with_exception
26
+ has_rich_text :rich_body
27
+
28
+ attribute :body_format, :string, default: -> { OpenBlog.config.default_body_format.to_s }
29
+ enum :body_format, { markdown: "markdown", rich_text: "rich_text" }, validate: true
30
+ enum :status, { draft: "draft", scheduled: "scheduled", published: "published", archived: "archived" }, validate: true
31
+ enum :provenance, { ai_assisted: "ai_assisted", human_written: "human_written", unknown: "unknown" }, validate: true, prefix: true
32
+
33
+ validates :title, :author_name, presence: true
34
+ validates :slug, presence: true, uniqueness: true, format: { with: SLUG_PATTERN }
35
+ validates :external_id, uniqueness: true, allow_nil: true
36
+ validates :canonical_url, length: { maximum: 2048 }, allow_nil: true
37
+ validates :body_format, inclusion: { in: ->(_post) { OpenBlog.config.body_formats.map(&:to_s) } }
38
+ validates :series_position, numericality: { only_integer: true, greater_than: 0 }, allow_nil: true
39
+ validates :series_position, uniqueness: { scope: :series_id }, if: -> { series_id && series_position }
40
+ validate :slug_is_available
41
+ validate :public_revision_ownership
42
+ validate :canonical_url_is_absolute
43
+
44
+ scope :listed, -> { where(status: "published") }
45
+
46
+ attr_accessor :release_context, :release_records
47
+
48
+ before_save :resolve_body_images
49
+ before_save :compute_derived
50
+ after_save :record_release
51
+ around_save :guard_parent_content, prepend: true
52
+ around_destroy :guard_parent_content, prepend: true
53
+
54
+ def path
55
+ "#{OpenBlog.mount_path.chomp('/')}/#{slug}"
56
+ end
57
+
58
+ def url(base: nil)
59
+ origin = base || OpenBlog.config.public_base_url
60
+ "#{origin.chomp('/')}#{path}" if origin
61
+ end
62
+
63
+ def faq_list
64
+ faqs.to_a.reject(&:marked_for_destruction?).sort_by { |entry| entry.position || 0 }.map do |entry|
65
+ { question: entry.question, answer: entry.answer }
66
+ end
67
+ end
68
+
69
+ def body_for_payload
70
+ return body_markdown.to_s if markdown?
71
+
72
+ if rich_body.will_save_change_to_body?
73
+ rich_body.read_attribute_for_database(:body).to_s
74
+ else
75
+ rich_body.read_attribute_before_type_cast(:body).to_s
76
+ end
77
+ end
78
+
79
+ private
80
+
81
+ def guard_parent_content(&block)
82
+ ContentGuard.with_parent(self, &block)
83
+ end
84
+
85
+ def record_release
86
+ republishing = saved_change_to_status? && status_before_last_save != "published"
87
+ self.release_records = RecordRelease.call(self, **(release_context || {}), republishing: republishing)
88
+ ensure
89
+ self.release_context = nil
90
+ end
91
+
92
+ def canonical_url_is_absolute
93
+ return if canonical_url.nil?
94
+
95
+ uri = URI.parse(canonical_url)
96
+ unless uri.is_a?(URI::HTTP) && uri.host.present?
97
+ errors.add(:canonical_url, "must be an absolute HTTP or HTTPS URL")
98
+ end
99
+ rescue URI::InvalidURIError
100
+ errors.add(:canonical_url, "must be an absolute HTTP or HTTPS URL")
101
+ end
102
+
103
+ def resolve_body_images
104
+ ImageResolution.native!(self)
105
+ end
106
+
107
+ def compute_derived
108
+ self.current_revision_identifier = RevisionPayload.new(self).identifier
109
+ body_text = markdown? ? PlainText.from_markdown(body_markdown) : PlainText.from_html(body_for_payload)
110
+ faq_text = faq_list.flat_map { |entry| entry.values_at(:question, :answer) }.join("\n")
111
+ self.word_count = [ body_text, faq_text ].join("\n").split.length
112
+ self.reading_time_minutes = [ (word_count / 238.0).ceil, 1 ].max
113
+ self.search_text = [ title, description, body_text, faq_text ].join("\n")
114
+ end
115
+
116
+ def slug_is_available
117
+ if (RESERVED_SLUGS + OpenBlog.config.route_segments.values).include?(slug)
118
+ errors.add(:slug, "is reserved")
119
+ end
120
+ redirect = Redirect.find_by(old_path: path)
121
+ if redirect && (new_record? || redirect.post_id != id)
122
+ errors.add(:slug, "belongs to an existing redirect")
123
+ end
124
+ end
125
+
126
+ def public_revision_ownership
127
+ if public_revision && (new_record? || public_revision.post_id != id)
128
+ errors.add(:public_revision, "must belong to this post")
129
+ end
130
+ end
131
+ end
132
+ end
@@ -0,0 +1,17 @@
1
+ module OpenBlog
2
+ class Publication < ApplicationRecord
3
+ include Immutable
4
+ belongs_to :post
5
+ belongs_to :revision
6
+
7
+ validates :entry_type, inclusion: { in: %w[first substantive correction maintenance adopted] }
8
+ validates :occurred_at, presence: true
9
+ validates :note, presence: true, if: -> { entry_type == "correction" }
10
+ validate :revision_belongs_to_post
11
+
12
+ private
13
+ def revision_belongs_to_post
14
+ errors.add(:revision, "must belong to the post") if revision && revision.post != post
15
+ end
16
+ end
17
+ end