docs-kit 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 (89) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +46 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +939 -0
  5. data/app/components/docs_ui/brand_mark.rb +88 -0
  6. data/app/components/docs_ui/callout.rb +37 -0
  7. data/app/components/docs_ui/code.rb +123 -0
  8. data/app/components/docs_ui/endpoint.rb +44 -0
  9. data/app/components/docs_ui/error_table.rb +72 -0
  10. data/app/components/docs_ui/example.rb +102 -0
  11. data/app/components/docs_ui/field_table.rb +46 -0
  12. data/app/components/docs_ui/header.rb +30 -0
  13. data/app/components/docs_ui/icon.rb +65 -0
  14. data/app/components/docs_ui/json_response.rb +46 -0
  15. data/app/components/docs_ui/markdown.rb +187 -0
  16. data/app/components/docs_ui/markdown_action.rb +45 -0
  17. data/app/components/docs_ui/on_this_page.rb +104 -0
  18. data/app/components/docs_ui/open_api_operation.rb +126 -0
  19. data/app/components/docs_ui/page.rb +83 -0
  20. data/app/components/docs_ui/page_helpers.rb +52 -0
  21. data/app/components/docs_ui/prop_table.rb +43 -0
  22. data/app/components/docs_ui/prose.rb +30 -0
  23. data/app/components/docs_ui/request_example.rb +85 -0
  24. data/app/components/docs_ui/search_box.rb +106 -0
  25. data/app/components/docs_ui/search_results.rb +95 -0
  26. data/app/components/docs_ui/section.rb +94 -0
  27. data/app/components/docs_ui/shell.rb +161 -0
  28. data/app/components/docs_ui/sidebar.rb +106 -0
  29. data/app/components/docs_ui/table.rb +64 -0
  30. data/app/components/docs_ui/theme_switcher.rb +46 -0
  31. data/app/components/docs_ui/topbar_links.rb +42 -0
  32. data/app/controllers/docs_kit/llms_controller.rb +76 -0
  33. data/app/controllers/docs_kit/mcp_controller.rb +60 -0
  34. data/app/controllers/docs_kit/search_controller.rb +72 -0
  35. data/app/javascript/docs_kit/controllers/docs_nav_controller.js +619 -0
  36. data/config/importmap.rb +15 -0
  37. data/config/rubocop/docs_kit.yml +24 -0
  38. data/exe/docs-kit +80 -0
  39. data/lib/docs-kit.rb +5 -0
  40. data/lib/docs_kit/api_client.rb +52 -0
  41. data/lib/docs_kit/api_request.rb +66 -0
  42. data/lib/docs_kit/api_templates.rb +92 -0
  43. data/lib/docs_kit/configuration.rb +485 -0
  44. data/lib/docs_kit/controller.rb +47 -0
  45. data/lib/docs_kit/engine.rb +49 -0
  46. data/lib/docs_kit/llms_text.rb +105 -0
  47. data/lib/docs_kit/markdown_export/blocks.rb +160 -0
  48. data/lib/docs_kit/markdown_export/inline.rb +95 -0
  49. data/lib/docs_kit/markdown_export/table.rb +53 -0
  50. data/lib/docs_kit/markdown_export.rb +92 -0
  51. data/lib/docs_kit/mcp_server.rb +128 -0
  52. data/lib/docs_kit/mcp_tools.rb +118 -0
  53. data/lib/docs_kit/nav_item.rb +22 -0
  54. data/lib/docs_kit/open_api/document.rb +91 -0
  55. data/lib/docs_kit/open_api/operation.rb +213 -0
  56. data/lib/docs_kit/open_api/schema.rb +178 -0
  57. data/lib/docs_kit/open_api.rb +55 -0
  58. data/lib/docs_kit/registry.rb +152 -0
  59. data/lib/docs_kit/rubocop.rb +19 -0
  60. data/lib/docs_kit/search_hit.rb +28 -0
  61. data/lib/docs_kit/search_index/snippet.rb +65 -0
  62. data/lib/docs_kit/search_index.rb +169 -0
  63. data/lib/docs_kit/shortcut.rb +99 -0
  64. data/lib/docs_kit/templates/new_site.rb +175 -0
  65. data/lib/docs_kit/topbar_link.rb +39 -0
  66. data/lib/docs_kit/version.rb +5 -0
  67. data/lib/docs_kit.rb +72 -0
  68. data/lib/generators/docs_kit/install/USAGE +15 -0
  69. data/lib/generators/docs_kit/install/install_generator.rb +447 -0
  70. data/lib/generators/docs_kit/install/sync_report.rb +64 -0
  71. data/lib/generators/docs_kit/install/templates/agents_md.erb +105 -0
  72. data/lib/generators/docs_kit/install/templates/application.tailwind.css.erb +39 -0
  73. data/lib/generators/docs_kit/install/templates/build-css +34 -0
  74. data/lib/generators/docs_kit/install/templates/build_css.rake +13 -0
  75. data/lib/generators/docs_kit/install/templates/doc.rb.erb +17 -0
  76. data/lib/generators/docs_kit/install/templates/docs_controller.rb.erb +14 -0
  77. data/lib/generators/docs_kit/install/templates/docs_kit.rb.erb +91 -0
  78. data/lib/generators/docs_kit/install/templates/installation_page.rb.erb +37 -0
  79. data/lib/generators/docs_kit/install/templates/landing.rb.erb +25 -0
  80. data/lib/generators/docs_kit/install/templates/landings_controller.rb.erb +7 -0
  81. data/lib/generators/docs_kit/install/templates/phlex.rb.erb +14 -0
  82. data/lib/generators/docs_kit/install/templates/rails_icons.rb.erb +12 -0
  83. data/lib/generators/docs_kit/install/templates/skill.md.erb +88 -0
  84. data/lib/generators/docs_kit/page/USAGE +26 -0
  85. data/lib/generators/docs_kit/page/page_generator.rb +127 -0
  86. data/lib/generators/docs_kit/page/templates/page.rb.erb +21 -0
  87. data/lib/rubocop/cop/docs_kit/escaped_interpolation_in_heredoc.rb +119 -0
  88. data/lib/rubocop/cop/docs_kit/render_component_preferred.rb +123 -0
  89. metadata +253 -0
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DocsUI
4
+ # Renders a brand/social logo as inline SVG, or falls through to a lucide icon.
5
+ #
6
+ # render DocsUI::BrandMark.new(:github, class: "size-5", label: "GitHub")
7
+ #
8
+ # lucide (the kit's synced icon set) ships NO brand logos — it dropped its brand
9
+ # icons, and rails_icons' brand library (simple_icons) is gone from the current
10
+ # `icons` gem. So the kit ships its own small, curated set of developer/social
11
+ # marks (BRANDS below), each an official Simple-Icons 24×24 single-path glyph.
12
+ #
13
+ # A token that IS a shipped brand (:github, :discord, …) renders that inline
14
+ # <svg>. Any other token is treated as a lucide icon name and delegated to
15
+ # DocsUI::Icon — so `icon: "book-open"` in a topbar link still works, and the
16
+ # brand set stays additive. The SVG is authored by the kit (a frozen constant),
17
+ # so `raw(safe(...))` is trusted markup, not config free text.
18
+ class BrandMark < Phlex::HTML
19
+ # token => official Simple-Icons path data (viewBox 0 0 24 24, fill rule
20
+ # nonzero). Sourced verbatim from simpleicons.org; each is a single <path d>.
21
+ # The path strings are inherently one long line each — never reflow them.
22
+ # rubocop:disable Layout/LineLength
23
+ BRANDS = {
24
+ github: "M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12",
25
+ gitlab: "m23.6004 9.5927-.0337-.0862L20.3.9814a.851.851 0 0 0-.3362-.405.8748.8748 0 0 0-.9997.0539.8748.8748 0 0 0-.29.4399l-2.2055 6.748H7.5375l-2.2057-6.748a.8573.8573 0 0 0-.29-.4412.8748.8748 0 0 0-.9997-.0537.8585.8585 0 0 0-.3362.4049L.4332 9.5015l-.0325.0862a6.0657 6.0657 0 0 0 2.0119 7.0105l.0113.0087.03.0213 4.976 3.7264 2.462 1.8633 1.4995 1.1321a1.0085 1.0085 0 0 0 1.2197 0l1.4995-1.1321 2.4619-1.8633 5.006-3.7489.0125-.01a6.0682 6.0682 0 0 0 2.0094-7.003z",
26
+ discord: "M20.317 4.3698a19.7913 19.7913 0 00-4.8851-1.5152.0741.0741 0 00-.0785.0371c-.211.3753-.4447.8648-.6083 1.2495-1.8447-.2762-3.68-.2762-5.4868 0-.1636-.3933-.4058-.8742-.6177-1.2495a.077.077 0 00-.0785-.037 19.7363 19.7363 0 00-4.8852 1.515.0699.0699 0 00-.0321.0277C.5334 9.0458-.319 13.5799.0992 18.0578a.0824.0824 0 00.0312.0561c2.0528 1.5076 4.0413 2.4228 5.9929 3.0294a.0777.0777 0 00.0842-.0276c.4616-.6304.8731-1.2952 1.226-1.9942a.076.076 0 00-.0416-.1057c-.6528-.2476-1.2743-.5495-1.8722-.8923a.077.077 0 01-.0076-.1277c.1258-.0943.2517-.1923.3718-.2914a.0743.0743 0 01.0776-.0105c3.9278 1.7933 8.18 1.7933 12.0614 0a.0739.0739 0 01.0785.0095c.1202.099.246.1981.3728.2924a.077.077 0 01-.0066.1276 12.2986 12.2986 0 01-1.873.8914.0766.0766 0 00-.0407.1067c.3604.698.7719 1.3628 1.225 1.9932a.076.076 0 00.0842.0286c1.961-.6067 3.9495-1.5219 6.0023-3.0294a.077.077 0 00.0313-.0552c.5004-5.177-.8382-9.6739-3.5485-13.6604a.061.061 0 00-.0312-.0286zM8.02 15.3312c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9555-2.4189 2.157-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.9555 2.4189-2.1569 2.4189zm7.9748 0c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9554-2.4189 2.1569-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.946 2.4189-2.1568 2.4189Z",
27
+ x: "M14.234 10.162 22.977 0h-2.072l-7.591 8.824L7.251 0H.258l9.168 13.343L.258 24H2.33l8.016-9.318L16.749 24h6.993zm-2.837 3.299-.929-1.329L3.076 1.56h3.182l5.965 8.532.929 1.329 7.754 11.09h-3.182z",
28
+ rubygems: "M7.81 7.9l-2.97 2.95 7.19 7.18 2.96-2.95 4.22-4.23-2.96-2.96v-.01H7.8zM12 0L1.53 6v12L12 24l10.47-6V6L12 0zm8.47 16.85L12 21.73l-8.47-4.88V7.12L12 2.24l8.47 4.88v9.73z",
29
+ bluesky: "M5.202 2.857C7.954 4.922 10.913 9.11 12 11.358c1.087-2.247 4.046-6.436 6.798-8.501C20.783 1.366 24 .213 24 3.883c0 .732-.42 6.156-.667 7.037-.856 3.061-3.978 3.842-6.755 3.37 4.854.826 6.089 3.562 3.422 6.299-5.065 5.196-7.28-1.304-7.847-2.97-.104-.305-.152-.448-.153-.327 0-.121-.05.022-.153.327-.568 1.666-2.782 8.166-7.847 2.97-2.667-2.737-1.432-5.473 3.422-6.3-2.777.473-5.899-.308-6.755-3.369C.42 10.04 0 4.615 0 3.883c0-3.67 3.217-2.517 5.202-1.026",
30
+ mastodon: "M23.268 5.313c-.35-2.578-2.617-4.61-5.304-5.004C17.51.242 15.792 0 11.813 0h-.03c-3.98 0-4.835.242-5.288.309C3.882.692 1.496 2.518.917 5.127.64 6.412.61 7.837.661 9.143c.074 1.874.088 3.745.26 5.611.118 1.24.325 2.47.62 3.68.55 2.237 2.777 4.098 4.96 4.857 2.336.792 4.849.923 7.256.38.265-.061.527-.132.786-.213.585-.184 1.27-.39 1.774-.753a.057.057 0 0 0 .023-.043v-1.809a.052.052 0 0 0-.02-.041.053.053 0 0 0-.046-.01 20.282 20.282 0 0 1-4.709.545c-2.73 0-3.463-1.284-3.674-1.818a5.593 5.593 0 0 1-.319-1.433.053.053 0 0 1 .066-.054c1.517.363 3.072.546 4.632.546.376 0 .75 0 1.125-.01 1.57-.044 3.224-.124 4.768-.422.038-.008.077-.015.11-.024 2.435-.464 4.753-1.92 4.989-5.604.008-.145.03-1.52.03-1.67.002-.512.167-3.63-.024-5.545zm-3.748 9.195h-2.561V8.29c0-1.309-.55-1.976-1.67-1.976-1.23 0-1.846.79-1.846 2.35v3.403h-2.546V8.663c0-1.56-.617-2.35-1.848-2.35-1.112 0-1.668.668-1.67 1.977v6.218H4.822V8.102c0-1.31.337-2.35 1.011-3.12.696-.77 1.608-1.164 2.74-1.164 1.311 0 2.302.5 2.962 1.498l.638 1.06.638-1.06c.66-.999 1.65-1.498 2.96-1.498 1.13 0 2.043.395 2.74 1.164.675.77 1.012 1.81 1.012 3.12z",
31
+ slack: "M5.042 15.165a2.528 2.528 0 0 1-2.52 2.523A2.528 2.528 0 0 1 0 15.165a2.527 2.527 0 0 1 2.522-2.52h2.52v2.52zM6.313 15.165a2.527 2.527 0 0 1 2.521-2.52 2.527 2.527 0 0 1 2.521 2.52v6.313A2.528 2.528 0 0 1 8.834 24a2.528 2.528 0 0 1-2.521-2.522v-6.313zM8.834 5.042a2.528 2.528 0 0 1-2.521-2.52A2.528 2.528 0 0 1 8.834 0a2.528 2.528 0 0 1 2.521 2.522v2.52H8.834zM8.834 6.313a2.528 2.528 0 0 1 2.521 2.521 2.528 2.528 0 0 1-2.521 2.521H2.522A2.528 2.528 0 0 1 0 8.834a2.528 2.528 0 0 1 2.522-2.521h6.312zM18.956 8.834a2.528 2.528 0 0 1 2.522-2.521A2.528 2.528 0 0 1 24 8.834a2.528 2.528 0 0 1-2.522 2.521h-2.522V8.834zM17.688 8.834a2.528 2.528 0 0 1-2.523 2.521 2.527 2.527 0 0 1-2.52-2.521V2.522A2.527 2.527 0 0 1 15.165 0a2.528 2.528 0 0 1 2.523 2.522v6.312zM15.165 18.956a2.528 2.528 0 0 1 2.523 2.522A2.528 2.528 0 0 1 15.165 24a2.527 2.527 0 0 1-2.52-2.522v-2.522h2.52zM15.165 17.688a2.527 2.527 0 0 1-2.52-2.523 2.526 2.526 0 0 1 2.52-2.52h6.313A2.527 2.527 0 0 1 24 15.165a2.528 2.528 0 0 1-2.522 2.523h-6.313z",
32
+ whatsapp: "M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.075-.297-.15-1.255-.463-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.52.149-.174.198-.298.298-.497.099-.198.05-.371-.025-.52-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51-.173-.008-.371-.01-.57-.01-.198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479 0 1.462 1.065 2.875 1.213 3.074.149.198 2.096 3.2 5.077 4.487.709.306 1.262.489 1.694.625.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.289.173-1.413-.074-.124-.272-.198-.57-.347m-5.421 7.403h-.004a9.87 9.87 0 01-5.031-1.378l-.361-.214-3.741.982.998-3.648-.235-.374a9.86 9.86 0 01-1.51-5.26c.001-5.45 4.436-9.884 9.888-9.884 2.64 0 5.122 1.03 6.988 2.898a9.825 9.825 0 012.893 6.994c-.003 5.45-4.437 9.884-9.885 9.884m8.413-18.297A11.815 11.815 0 0012.05 0C5.495 0 .16 5.335.157 11.892c0 2.096.547 4.142 1.588 5.945L.057 24l6.305-1.654a11.882 11.882 0 005.683 1.448h.005c6.554 0 11.89-5.335 11.893-11.893a11.821 11.821 0 00-3.48-8.413Z",
33
+ telegram: "M11.944 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0a12 12 0 0 0-.056 0zm4.962 7.224c.1-.002.321.023.465.14a.506.506 0 0 1 .171.325c.016.093.036.306.02.472-.18 1.898-.962 6.502-1.36 8.627-.168.9-.499 1.201-.82 1.23-.696.065-1.225-.46-1.9-.902-1.056-.693-1.653-1.124-2.678-1.8-1.185-.78-.417-1.21.258-1.91.177-.184 3.247-2.977 3.307-3.23.007-.032.014-.15-.056-.212s-.174-.041-.249-.024c-.106.024-1.793 1.14-5.061 3.345-.48.33-.913.49-1.302.48-.428-.008-1.252-.241-1.865-.44-.752-.245-1.349-.374-1.297-.789.027-.216.325-.437.893-.663 3.498-1.524 5.83-2.529 6.998-3.014 3.332-1.386 4.025-1.627 4.476-1.635z",
34
+ linkedin: "M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433c-1.144 0-2.063-.926-2.063-2.065 0-1.138.92-2.063 2.063-2.063 1.14 0 2.064.925 2.064 2.063 0 1.139-.925 2.065-2.064 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z",
35
+ youtube: "M23.498 6.186a3.016 3.016 0 0 0-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.017 3.017 0 0 0 .502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.016 3.016 0 0 0 2.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.015 3.015 0 0 0 2.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814zM9.545 15.568V8.432L15.818 12l-6.273 3.568z",
36
+ reddit: "M12 0C5.373 0 0 5.373 0 12c0 3.314 1.343 6.314 3.515 8.485l-2.286 2.286C.775 23.225 1.097 24 1.738 24H12c6.627 0 12-5.373 12-12S18.627 0 12 0Zm4.388 3.199c1.104 0 1.999.895 1.999 1.999 0 1.105-.895 2-1.999 2-.946 0-1.739-.657-1.947-1.539v.002c-1.147.162-2.032 1.15-2.032 2.341v.007c1.776.067 3.4.567 4.686 1.363.473-.363 1.064-.58 1.707-.58 1.547 0 2.802 1.254 2.802 2.802 0 1.117-.655 2.081-1.601 2.531-.088 3.256-3.637 5.876-7.997 5.876-4.361 0-7.905-2.617-7.998-5.87-.954-.447-1.614-1.415-1.614-2.538 0-1.548 1.255-2.802 2.803-2.802.645 0 1.239.218 1.712.585 1.275-.79 2.881-1.291 4.64-1.365v-.01c0-1.663 1.263-3.034 2.88-3.207.188-.911.993-1.595 1.959-1.595Zm-8.085 8.376c-.784 0-1.459.78-1.506 1.797-.047 1.016.64 1.429 1.426 1.429.786 0 1.371-.369 1.418-1.385.047-1.017-.553-1.841-1.338-1.841Zm7.406 0c-.786 0-1.385.824-1.338 1.841.047 1.017.634 1.385 1.418 1.385.785 0 1.473-.413 1.426-1.429-.046-1.017-.721-1.797-1.506-1.797Zm-3.703 4.013c-.974 0-1.907.048-2.77.135-.147.015-.241.168-.183.305.483 1.154 1.622 1.964 2.953 1.964 1.33 0 2.47-.81 2.953-1.964.057-.137-.037-.29-.184-.305-.863-.087-1.795-.135-2.769-.135Z",
37
+ stackoverflow: "M15.725 0l-1.72 1.277 6.39 8.588 1.716-1.277L15.725 0zm-3.94 3.418l-1.369 1.644 8.225 6.85 1.369-1.644-8.225-6.85zm-3.15 4.465l-.905 1.94 9.702 4.517.904-1.94-9.701-4.517zm-1.85 4.86l-.44 2.093 10.473 2.201.44-2.092-10.473-2.203zM1.89 15.47V24h19.19v-8.53h-2.133v6.397H4.021v-6.396H1.89zm4.265 2.133v2.13h10.66v-2.13H6.154Z"
38
+ }.freeze
39
+ # rubocop:enable Layout/LineLength
40
+
41
+ # True when `token` names a shipped brand mark (symbol or string), false for a
42
+ # lucide name, nil, or a blank string. The Shell uses this to decide whether a
43
+ # topbar link's icon renders as a brand <svg> or a lucide glyph.
44
+ def self.brand?(token)
45
+ return false if token.nil? || token.to_s.empty?
46
+
47
+ BRANDS.key?(token.to_sym)
48
+ end
49
+
50
+ # icon: a brand key (:github, …) or a lucide icon name.
51
+ # label: the accessible name — rendered as the SVG <title> (brand) so a
52
+ # screen reader announces "GitHub", never an empty graphic.
53
+ def initialize(icon, label: nil, **attributes)
54
+ @icon = icon
55
+ @label = label
56
+ @attributes = attributes
57
+ end
58
+
59
+ def view_template
60
+ if self.class.brand?(@icon)
61
+ brand_svg
62
+ else
63
+ # Not a shipped brand — treat the token as a lucide icon name. Threads the
64
+ # same attributes (class, etc.); DocsUI::Icon degrades to nothing if the
65
+ # name isn't synced, exactly as elsewhere in the chrome.
66
+ render DocsUI::Icon.new(@icon.to_s, **@attributes)
67
+ end
68
+ end
69
+
70
+ private
71
+
72
+ # The inline brand <svg>. The path comes from the frozen BRANDS constant
73
+ # (kit-authored, never config free text), so raw(safe(...)) is trusted markup.
74
+ def brand_svg
75
+ svg(
76
+ xmlns: "http://www.w3.org/2000/svg",
77
+ viewBox: "0 0 24 24",
78
+ fill: "currentColor",
79
+ role: "img",
80
+ aria_label: @label,
81
+ **@attributes
82
+ ) do
83
+ title { @label } if @label
84
+ raw(safe(%(<path d="#{BRANDS[@icon.to_sym]}"/>)))
85
+ end
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DocsUI
4
+ # A callout box (note / tip / warning) for the docs. daisyUI alert styling + a
5
+ # lucide icon per level.
6
+ #
7
+ # render DocsUI::Callout.new(:warning) { "Restart the server after…" }
8
+ class Callout < Phlex::HTML
9
+ LEVELS = {
10
+ note: { klass: "alert-info", icon: "info" },
11
+ tip: { klass: "alert-success", icon: "lightbulb" },
12
+ warning: { klass: "alert-warning", icon: "triangle-alert" }
13
+ }.freeze
14
+
15
+ def initialize(level = :note, title: nil)
16
+ # Normalize an unknown level to :note so both the styling and the
17
+ # data-md-callout export hint agree (never a bogus level name leaking out).
18
+ @level = LEVELS.key?(level) ? level : :note
19
+ @config = LEVELS[@level]
20
+ @title = title
21
+ end
22
+
23
+ def view_template(&)
24
+ # data-md-callout carries the level (note/tip/warning) so
25
+ # DocsKit::MarkdownExport renders `> **Tip:** …` without reverse-engineering
26
+ # the level from the alert-* class.
27
+ div(class: "not-prose alert #{@config[:klass]} my-4 items-start", role: "note",
28
+ data: { md_callout: @level }) do
29
+ render DocsUI::Icon.new(@config[:icon], class: "size-5 shrink-0")
30
+ div do
31
+ div(class: "font-semibold") { @title } if @title
32
+ div(class: "text-sm", &)
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,123 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rouge"
4
+
5
+ module DocsUI
6
+ # A syntax-highlighted code block for docs and demo panels. Rouge does the
7
+ # highlighting; an optional filename/label sits in a title bar like a real docs
8
+ # code sample. Self-contained: it injects its own Rouge theme CSS so no separate
9
+ # stylesheet asset is required.
10
+ #
11
+ # render DocsUI::Code.new(ruby_source) # ruby, no title
12
+ # render DocsUI::Code.new(py, lexer: :python, filename: "a.py") # any language
13
+ #
14
+ # Any language Rouge knows (~200 lexers) works by its name or alias — python,
15
+ # go, rust, elixir, kotlin, swift, json, dockerfile, ... — no allowlist. Add
16
+ # friendly lexer aliases via DocsKit.configure (code_lexer_aliases). An unknown
17
+ # language falls back to plaintext (never raises). (Tab labels are a
18
+ # DocsUI::Example concern — set via code_language_labels, not here; Code has no
19
+ # label, only a filename.)
20
+ class Code < Phlex::HTML
21
+ include Phlex::Rails::Helpers::ContentSecurityPolicyNonce
22
+
23
+ FORMATTER = Rouge::Formatters::HTML.new
24
+
25
+ def initialize(source, lexer: :ruby, filename: nil)
26
+ @source = source.to_s.strip
27
+ @lexer = lexer
28
+ @filename = filename
29
+ end
30
+
31
+ def view_template
32
+ # Nonce the inline theme CSS so it survives a nonce-based style-src. Off a
33
+ # request there is no nonce (see #csp_nonce) and Phlex omits a nil-valued
34
+ # attribute, so the no-nonce markup is unchanged.
35
+ style(nonce: csp_nonce) { highlight_css }
36
+ resolved = lexer
37
+ div(class: "not-prose my-4 overflow-hidden rounded-box border border-base-300 bg-base-300/40") do
38
+ title_bar if @filename
39
+ # data-md-lang carries the RESOLVED Rouge tag (ruby/python/plaintext/…) so
40
+ # DocsKit::MarkdownExport emits a ```lang fence without re-resolving the
41
+ # language. It's the real lexer tag, not the requested alias.
42
+ div(class: "code-highlight overflow-x-auto p-4 text-sm leading-relaxed", data: { md_lang: resolved.tag }) do
43
+ pre { raw(safe(FORMATTER.format(resolved.lex(@source)))) }
44
+ end
45
+ end
46
+ end
47
+
48
+ private
49
+
50
+ # The request's CSP nonce, or nil when there's no Rails view context (an
51
+ # isolated Phlex render, or a host that doesn't nonce style-src). The
52
+ # phlex-rails value helper delegates to view_context, which raises without
53
+ # one, so guard on its presence — a nil result makes Phlex omit the
54
+ # attribute, keeping the un-nonced markup unchanged.
55
+ def csp_nonce = view_context && content_security_policy_nonce
56
+
57
+ def title_bar
58
+ # data-md-skip: the title bar is chrome. MarkdownExport strips it whole
59
+ # before the visitor runs, so the filename never leaks into the .md twin as
60
+ # a stray line above the fence. The visible HTML is unaffected (DROP_SELECTOR
61
+ # is applied only inside #to_md).
62
+ div(class: "flex items-center gap-2 border-b border-base-300 bg-base-300/60 px-4 py-2",
63
+ data: { md_skip: true }) do
64
+ render DocsUI::Icon.new("file-code", class: "size-3.5 opacity-60")
65
+ span(class: "font-mono text-xs opacity-70") { @filename }
66
+ end
67
+ end
68
+
69
+ # Resolve @lexer to a Rouge lexer instance. Order: an explicit Rouge::Lexer
70
+ # class/instance passed through; a configured friendly alias; Rouge's own
71
+ # registry (name/alias); then the configured fallback (plaintext).
72
+ def lexer
73
+ explicit_lexer || (find_lexer(@lexer.to_s) || Rouge::Lexers::PlainText).new
74
+ end
75
+
76
+ # A Rouge::Lexer instance passed directly (class or instance), else nil.
77
+ def explicit_lexer
78
+ return @lexer if @lexer.is_a?(Rouge::Lexer)
79
+ return @lexer.new if @lexer.is_a?(Class) && @lexer < Rouge::Lexer
80
+
81
+ nil
82
+ end
83
+
84
+ # Find a lexer CLASS by name: configured alias → Rouge registry → fallback.
85
+ def find_lexer(name)
86
+ config = DocsKit.configuration
87
+ aliased = config.lexer_aliases[name.to_sym]
88
+ (aliased && Rouge::Lexer.find(aliased.to_s)) ||
89
+ Rouge::Lexer.find(name) ||
90
+ Rouge::Lexer.find(config.code_lexer_fallback.to_s)
91
+ end
92
+
93
+ # Static Rouge theme CSS — no user input. Phlex safe(), not html_safe.
94
+ #
95
+ # The base (light) theme is emitted un-scoped so it applies to every theme.
96
+ # When a dark theme is configured (config.code_theme_dark), its CSS is
97
+ # additionally emitted scoped under [data-theme=X] .code-highlight for each
98
+ # shipped dark theme, so daisyUI's more-specific [data-theme] selector wins
99
+ # and code blocks restyle with the switcher — CSS-only, no JS, no flash.
100
+ # With no dark theme configured this reduces to the original single-theme
101
+ # output byte-for-byte (backwards compatible).
102
+ def highlight_css
103
+ theme = DocsKit.configuration.code_theme_class
104
+ raw(safe(<<~CSS))
105
+ #{theme.render(scope: '.code-highlight')}#{dark_highlight_css}
106
+ .code-highlight pre { margin: 0; white-space: pre-wrap; word-break: break-word; }
107
+ CSS
108
+ end
109
+
110
+ # The dark theme's CSS, one block per shipped dark theme, each scoped under
111
+ # [data-theme=X] .code-highlight. Empty string when no dark theme is
112
+ # configured (or no shipped theme is dark) so #highlight_css is unchanged.
113
+ def dark_highlight_css
114
+ config = DocsKit.configuration
115
+ dark = config.code_theme_dark_class
116
+ return "" if dark.nil?
117
+
118
+ config.dark_themes_shipped.map do |name|
119
+ "\n#{dark.render(scope: "[data-theme=#{name}] .code-highlight")}"
120
+ end.join
121
+ end
122
+ end
123
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DocsUI
4
+ # An HTTP endpoint reference line — a method badge followed by the path — in the
5
+ # kit's daisyUI look. This is the `code(class: "badge …")` lambda every API page
6
+ # was hand-rolling; compose it instead.
7
+ #
8
+ # render DocsUI::Endpoint.new(:post, "/v1/messages")
9
+ # # => POST /v1/messages (POST as a primary badge, path monospace)
10
+ #
11
+ # It renders INLINE (no block wrapper), so it drops straight into a Section
12
+ # description or a run of prose:
13
+ #
14
+ # DocsUI::Section("Create a message", description: DocsUI::Endpoint.new(:post, "/v1/messages"))
15
+ #
16
+ # The verb → badge-colour map is an explicit frozen Hash of LITERAL class
17
+ # strings so the Tailwind scan (which reads the gem's Ruby) sees every badge
18
+ # class and generates it. An unknown verb falls back to a neutral badge and
19
+ # never raises — a typo degrades gracefully rather than blowing up a render.
20
+ class Endpoint < Phlex::HTML
21
+ # Each value is a single literal string (not interpolated) so Tailwind's
22
+ # source scan generates the colour. Keep these literal — see Critical Rule 6.
23
+ BADGE_CLASSES = {
24
+ "GET" => "badge badge-sm badge-success",
25
+ "POST" => "badge badge-sm badge-primary",
26
+ "PUT" => "badge badge-sm badge-warning",
27
+ "PATCH" => "badge badge-sm badge-warning",
28
+ "DELETE" => "badge badge-sm badge-error"
29
+ }.freeze
30
+
31
+ NEUTRAL_BADGE = "badge badge-sm badge-neutral"
32
+
33
+ def initialize(method, path)
34
+ @method = method.to_s.upcase
35
+ @path = path
36
+ end
37
+
38
+ def view_template
39
+ code(class: BADGE_CLASSES.fetch(@method, NEUTRAL_BADGE)) { plain @method }
40
+ whitespace
41
+ code { plain @path }
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DocsUI
4
+ # An error reference table for an API endpoint — a keyword-schema preset over
5
+ # DocsUI::Table. Each error is a Hash:
6
+ #
7
+ # render DocsUI::ErrorTable.new(
8
+ # [
9
+ # { scenario: "Missing or invalid API key", status: "401", type: "authentication_error" },
10
+ # { scenario: "Non-HTTPS URL", status: "422", type: "validation_error", param: "url" },
11
+ # ]
12
+ # )
13
+ #
14
+ # Columns: Scenario / Status / Type (auto code-styled) / Param (auto code-styled).
15
+ # The Param column is shown only when at least one error names a param — an
16
+ # endpoint whose errors are all param-free renders a clean three-column table.
17
+ # When the column IS shown, a param-free row gets the canonical em-dash `—`.
18
+ #
19
+ # `type:` is optional — OpenAPI has no canonical error-type field, so an
20
+ # OpenAPI-derived error may carry none. A row without a (present) type gets the
21
+ # em-dash `—` in the Type column, not an empty <code>; a row with `type:`
22
+ # renders it code-styled exactly as before.
23
+ class ErrorTable < Phlex::HTML
24
+ BASE_HEADERS = %w[Scenario Status Type].freeze
25
+ PARAM_HEADER = "Param"
26
+
27
+ # Shared with FieldTable's canonical "no value" placeholder.
28
+ NO_PARAM = "—"
29
+
30
+ def initialize(errors)
31
+ @errors = errors
32
+ @with_param = errors.any? { |error| present_param?(error[:param]) }
33
+ end
34
+
35
+ def view_template
36
+ render DocsUI::Table.new(headers, @errors.map { |error| row(error) })
37
+ end
38
+
39
+ private
40
+
41
+ def headers
42
+ @with_param ? [*BASE_HEADERS, PARAM_HEADER] : BASE_HEADERS
43
+ end
44
+
45
+ def row(error)
46
+ cells = [
47
+ error.fetch(:scenario),
48
+ error.fetch(:status),
49
+ type_cell(error)
50
+ ]
51
+ cells << param_cell(error) if @with_param
52
+ cells
53
+ end
54
+
55
+ # The Type cell: code-styled when present, else the em-dash placeholder. A
56
+ # blank string counts as absent (same rule as #param_cell / #present_param?).
57
+ def type_cell(error)
58
+ type = error[:type]
59
+ present_param?(type) ? [:code, type] : NO_PARAM
60
+ end
61
+
62
+ def param_cell(error)
63
+ param = error[:param]
64
+ present_param?(param) ? [:code, param] : NO_PARAM
65
+ end
66
+
67
+ # A blank string is not a param — it flips no column and gets the em-dash.
68
+ def present_param?(param)
69
+ !param.nil? && !param.to_s.strip.empty?
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DocsUI
4
+ # A multi-language code group: the same example shown in several languages, with
5
+ # tabs to switch. The chosen language is a GLOBAL sticky preference (localStorage
6
+ # via the docs-nav controller) — pick Ruby once and every code group on this and
7
+ # future pages shows Ruby, falling back to an available language when a group
8
+ # doesn't have the chosen one.
9
+ #
10
+ # render DocsUI::Example.new do |ex|
11
+ # ex.code(:ruby, filename: "client.rb") do
12
+ # <<~RUBY
13
+ # Anthropic.messages.create(model: "claude-opus-4-8", ...)
14
+ # RUBY
15
+ # end
16
+ # ex.code(:python, filename: "client.py") do
17
+ # <<~PY
18
+ # client.messages.create(model="claude-opus-4-8", ...)
19
+ # PY
20
+ # end
21
+ # end
22
+ #
23
+ # With one snippet it degrades to a plain DocsUI::Code (no tabs). With JS off the
24
+ # first language shows and the rest are visible below it (progressive
25
+ # enhancement — no content is hidden without JS).
26
+ class Example < Phlex::HTML
27
+ def initialize
28
+ @snippets = []
29
+ end
30
+
31
+ # Collect one language's snippet. `lang` is the language token (e.g. :ruby,
32
+ # :python, :go) — Docs::Code resolves it against Rouge's full registry + the
33
+ # configured aliases, so any language works. The tab label comes from the
34
+ # configured language_labels (else the token capitalized), or an explicit
35
+ # `label:` override (used by RequestExample so a client carries its own tab
36
+ # name). filename/lexer are optional; lexer defaults to the language token.
37
+ # The block returns the source.
38
+ def code(lang, filename: nil, lexer: nil, label: nil)
39
+ token = lang.to_sym
40
+ @snippets << {
41
+ lang: token,
42
+ label: label || DocsKit.configuration.language_labels.fetch(token, token.to_s.capitalize),
43
+ filename: filename,
44
+ lexer: lexer || token,
45
+ source: yield.to_s
46
+ }
47
+ nil
48
+ end
49
+
50
+ def view_template
51
+ yield self if block_given?
52
+ return if @snippets.empty?
53
+ return render_single if @snippets.one?
54
+
55
+ div(
56
+ class: "not-prose my-4",
57
+ data: { docs_nav_target: "codeGroup" }
58
+ ) do
59
+ language_tabs
60
+ snippet_panels
61
+ end
62
+ end
63
+
64
+ private
65
+
66
+ def render_single
67
+ snippet = @snippets.first
68
+ render DocsUI::Code.new(snippet[:source], lexer: snippet[:lexer], filename: snippet[:filename])
69
+ end
70
+
71
+ def language_tabs
72
+ div(role: "tablist", class: "tabs tabs-box w-fit mb-2") do
73
+ @snippets.each do |snippet|
74
+ button(
75
+ role: "tab",
76
+ class: "tab",
77
+ data: {
78
+ docs_nav_target: "codeTab",
79
+ docs_nav_lang_param: snippet[:lang],
80
+ action: "docs-nav#selectLanguage",
81
+ testid: "code-lang-#{snippet[:lang]}"
82
+ }
83
+ ) { snippet[:label] }
84
+ end
85
+ end
86
+ end
87
+
88
+ def snippet_panels
89
+ @snippets.each do |snippet|
90
+ div(
91
+ data: {
92
+ docs_nav_target: "codePanel",
93
+ docs_nav_lang_param: snippet[:lang],
94
+ lang: snippet[:lang]
95
+ }
96
+ ) do
97
+ render DocsUI::Code.new(snippet[:source], lexer: snippet[:lexer], filename: snippet[:filename])
98
+ end
99
+ end
100
+ end
101
+ end
102
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DocsUI
4
+ # A parameter/field reference table for an API object or request body — a
5
+ # keyword-schema preset over DocsUI::Table. Each field is a Hash:
6
+ #
7
+ # render DocsUI::FieldTable.new(
8
+ # [
9
+ # { name: "url", type: "string", required: true, description: "HTTPS destination URL." },
10
+ # { name: "description", type: "string", description: "Optional internal label." },
11
+ # { name: "events", type: "array", required: true, description: [:md, "e.g. `payment_link.paid`"] },
12
+ # ]
13
+ # )
14
+ #
15
+ # Columns: Name (auto code-styled) / Type / Required (✓ or the canonical em-dash
16
+ # `—`) / Description. `required:` defaults to false. The description cell follows
17
+ # DocsUI::Table's convention — a plain String is escaped text, `[:code, "x"]` is
18
+ # inline code, `[:md, "…"]` is inline Markdown.
19
+ class FieldTable < Phlex::HTML
20
+ HEADERS = %w[Name Type Required Description].freeze
21
+
22
+ # The ONE canonical "no value" placeholder across the whole kit — never the
23
+ # ASCII hyphen "-", never a bare "—" typed ad hoc in a page.
24
+ REQUIRED_YES = "✓"
25
+ REQUIRED_NO = "—"
26
+
27
+ def initialize(fields)
28
+ @fields = fields
29
+ end
30
+
31
+ def view_template
32
+ render DocsUI::Table.new(HEADERS, @fields.map { |field| row(field) })
33
+ end
34
+
35
+ private
36
+
37
+ def row(field)
38
+ [
39
+ [:code, field.fetch(:name)],
40
+ field.fetch(:type),
41
+ field.fetch(:required, false) ? REQUIRED_YES : REQUIRED_NO,
42
+ field.fetch(:description, REQUIRED_NO)
43
+ ]
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DocsUI
4
+ # A doc page header: an optional eyebrow (kicker), the title, and a lead
5
+ # paragraph. Gives every doc page a consistent masthead.
6
+ #
7
+ # render DocsUI::Header.new("Installation", eyebrow: "Guide") do
8
+ # plain "Add the gem and render your first component."
9
+ # end
10
+ #
11
+ # The primary argument (the title) is positional, matching Section/Code and the
12
+ # kit-wide convention. The legacy `title:` kwarg still works so existing sites
13
+ # keep rendering unchanged; the positional wins if both are given.
14
+ class Header < Phlex::HTML
15
+ # Positional title (the convention), with a silent `title:` kwarg fallback for
16
+ # sites that still pass it by keyword. Positional wins when both are given.
17
+ def initialize(title = nil, eyebrow: nil, **opts)
18
+ @title = title || opts[:title]
19
+ @eyebrow = eyebrow
20
+ end
21
+
22
+ def view_template(&block)
23
+ header(class: "mb-8 border-b border-base-300 pb-6") do
24
+ div(class: "mb-2 text-xs font-semibold uppercase tracking-wider text-primary") { @eyebrow } if @eyebrow
25
+ h1(class: "text-3xl font-bold tracking-tight md:text-4xl") { @title }
26
+ p(class: "mt-3 text-lg text-base-content/70", &block) if block
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DocsUI
4
+ # Renders a synced lucide icon as inline SVG via rails_icons. Thin Phlex wrapper.
5
+ #
6
+ # render DocsUI::Icon.new("search", class: "size-4")
7
+ #
8
+ # A missing icon name falls back to a question-mark glyph outside development,
9
+ # rather than raising, so a typo never takes down a docs page in production.
10
+ class Icon < Phlex::HTML
11
+ MISSING_ICON = "circle-question-mark"
12
+
13
+ def initialize(name, **attributes)
14
+ @name = name
15
+ @attributes = attributes
16
+ end
17
+
18
+ def view_template
19
+ # rails_icons is a Railtie gem; outside a Rails host (isolated Phlex tests)
20
+ # it isn't loaded, and in a not-yet-fully-set-up app its default library may
21
+ # be unconfigured. Either way, render nothing rather than take down the page
22
+ # — icons are chrome, not content. In development we still raise so a real
23
+ # misconfiguration is visible.
24
+ return unless defined?(::Icons::Icon) && rails_icons_library
25
+
26
+ svg = svg_for(@name, **@attributes)
27
+ raw(safe(svg)) if svg
28
+ end
29
+
30
+ private
31
+
32
+ def svg_for(name, **arguments)
33
+ ::Icons::Icon.new(
34
+ name: name.to_s.dasherize,
35
+ library: rails_icons_library,
36
+ variant: nil,
37
+ arguments: arguments
38
+ ).svg
39
+ rescue ::Icons::IconNotFound
40
+ raise if local_env?
41
+
42
+ begin
43
+ ::Icons::Icon.new(name: MISSING_ICON, library: rails_icons_library, variant: nil, arguments: arguments).svg
44
+ rescue StandardError
45
+ nil # even the fallback glyph isn't synced — render nothing.
46
+ end
47
+ rescue StandardError
48
+ # Library misconfigured / icon set not synced. Surface it in dev; elsewhere
49
+ # degrade to no icon rather than 500 the whole docs page.
50
+ raise if local_env?
51
+
52
+ nil
53
+ end
54
+
55
+ def rails_icons_library
56
+ DocsKit.configuration.icon_library || ::RailsIcons.configuration.default_library
57
+ rescue StandardError
58
+ nil
59
+ end
60
+
61
+ def local_env?
62
+ defined?(Rails) && Rails.env.local?
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module DocsUI
6
+ # A pretty-printed JSON response block. Give it a Ruby Hash (deep-stringified and
7
+ # JSON.pretty_generate'd) or a pre-formatted String; it renders a DocsUI::Code
8
+ # with the json lexer and a filename title bar. Kills the hand-rolled
9
+ # deep_stringify + JSON.pretty_generate every API page was copy-pasting.
10
+ #
11
+ # render DocsUI::JsonResponse.new({ id: "obj_1", status: "active" })
12
+ # render DocsUI::JsonResponse.new(raw_json_string, filename: "webhook.json")
13
+ #
14
+ # A Hash with symbol keys renders as real JSON (string keys, no :symbol / =>
15
+ # leaking through). A String is passed through verbatim (already formatted).
16
+ class JsonResponse < Phlex::HTML
17
+ def initialize(body, filename: "response.json")
18
+ @body = body
19
+ @filename = filename
20
+ end
21
+
22
+ def view_template
23
+ render DocsUI::Code.new(json_source, lexer: :json, filename: @filename)
24
+ end
25
+
26
+ private
27
+
28
+ # The JSON string to highlight: a String passes through; a Hash/Array is
29
+ # deep-stringified then pretty-generated so it reads like an API response.
30
+ def json_source
31
+ return @body if @body.is_a?(String)
32
+
33
+ JSON.pretty_generate(deep_stringify(@body))
34
+ end
35
+
36
+ # Recursively stringify keys and symbol values so the output is real JSON.
37
+ def deep_stringify(value)
38
+ case value
39
+ when Hash then value.to_h { |k, v| [k.to_s, deep_stringify(v)] }
40
+ when Array then value.map { |v| deep_stringify(v) }
41
+ when Symbol then value.to_s
42
+ else value
43
+ end
44
+ end
45
+ end
46
+ end