scriptorium 0.0.2 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (290) hide show
  1. checksums.yaml +4 -4
  2. data/README.lt3 +324 -0
  3. data/README.md +3155 -1
  4. data/assets/.DS_Store +0 -0
  5. data/assets/README.md +44 -0
  6. data/assets/back-icon.png +0 -0
  7. data/assets/icons/facebook.svg +1 -0
  8. data/assets/icons/github.svg +1 -0
  9. data/assets/icons/instagram.svg +1 -0
  10. data/assets/icons/reddit.svg +1 -0
  11. data/assets/icons/ui/.DS_Store +0 -0
  12. data/assets/icons/ui/back.png +0 -0
  13. data/assets/icons/ui/copy.png +0 -0
  14. data/assets/icons/ui/down.png +0 -0
  15. data/assets/icons/ui/end.png +0 -0
  16. data/assets/icons/ui/exit.png +0 -0
  17. data/assets/icons/ui/foo +10 -0
  18. data/assets/icons/ui/home.png +0 -0
  19. data/assets/icons/ui/left.png +0 -0
  20. data/assets/icons/ui/next.png +0 -0
  21. data/assets/icons/ui/right.png +0 -0
  22. data/assets/icons/ui/start.png +0 -0
  23. data/assets/icons/ui/up.png +0 -0
  24. data/assets/icons/x.svg +1 -0
  25. data/assets/icons/youtube.svg +1 -0
  26. data/assets/samples/placeholder.svg +9 -0
  27. data/assets/themes/standard/favicon.svg +6 -0
  28. data/bin/scriptorium +1511 -0
  29. data/doc/README.txt +6 -0
  30. data/doc/anti-amnesia/20250727-054000-scriptorium-overview.md +95 -0
  31. data/doc/anti-amnesia/20250727-060000-api-design-tui-planning.md +34 -0
  32. data/doc/anti-amnesia/20250727-061000-runeblog-tui-analysis.md +50 -0
  33. data/doc/anti-amnesia/20250727-123000-anti-amnesia-conventions.md +31 -0
  34. data/doc/anti-amnesia/20250727-154000-livetext-plugin-file-stats.md +73 -0
  35. data/doc/anti-amnesia/20250727-172600-cursor-rbenv-ruby-version-mystery.md +64 -0
  36. data/doc/anti-amnesia/20250727-172600-unified-minitest-framework.md +70 -0
  37. data/doc/anti-amnesia/20250727-172900-ai-cognitive-assessment-capabilities.md +40 -0
  38. data/doc/anti-amnesia/20250727-173000-widget-testing-achievement.md +110 -0
  39. data/doc/anti-amnesia/20250727-180000-post-id-num-refactoring.md +73 -0
  40. data/doc/anti-amnesia/20250728-124243-aaa-syntax-clarification.md +46 -0
  41. data/doc/anti-amnesia/20250728-124421-conversation-summary-concise.md +124 -0
  42. data/doc/anti-amnesia/20250729-190000-scriptorium-tui-testing-complete.md +46 -0
  43. data/doc/anti-amnesia/20250729-200000-scriptorium-tui-testing-edit-file-workflow.md +97 -0
  44. data/doc/anti-amnesia/20250729-210000-reddit-autopost-integration-complete.md +158 -0
  45. data/doc/anti-amnesia/20250729-211500-dependency-management-system.md +211 -0
  46. data/doc/anti-amnesia/20250729-213000-python-virtual-environment-setup.md +141 -0
  47. data/doc/anti-amnesia/20250729-214500-theme-management-commands.md +211 -0
  48. data/doc/anti-amnesia/20250729-215000-version-update-to-0.6.0.md +134 -0
  49. data/doc/anti-amnesia/20250729-220000-user-guide-complete.md +41 -0
  50. data/doc/anti-amnesia/20250804-190500-cognitive-loop-bug.md +45 -0
  51. data/doc/anti-amnesia/20250804-190700-anti-amnesia-timestamping-fix.md +30 -0
  52. data/doc/anti-amnesia/20250804-213700-publishing-test-fix.md +49 -0
  53. data/doc/anti-amnesia/20250804-214400-additional-test-fixes.md +46 -0
  54. data/doc/anti-amnesia/20250804-220000-asset-function-logic-clarification.md +41 -0
  55. data/doc/anti-amnesia/20250806-202032-asset-function-logic-clarification.md +41 -0
  56. data/doc/anti-amnesia/20250807-213025.md +116 -0
  57. data/doc/anti-amnesia/20250813-082428-syntax-highlighting-and-navigation-improvements.md +256 -0
  58. data/doc/banner_svg_config.md +114 -0
  59. data/doc/contrib.lt3 +8 -0
  60. data/doc/dependencies.md +281 -0
  61. data/doc/hacker.lt3 +5 -0
  62. data/doc/reddit_credentials_template.json +8 -0
  63. data/doc/reddit_integration.md +207 -0
  64. data/doc/user.lt3 +38 -0
  65. data/doc/user_guide_section_1.md +137 -0
  66. data/doc/user_guide_section_10.md +515 -0
  67. data/doc/user_guide_section_11.md +708 -0
  68. data/doc/user_guide_section_2.md +233 -0
  69. data/doc/user_guide_section_3.md +5 -0
  70. data/doc/user_guide_section_4.md +221 -0
  71. data/doc/user_guide_section_5.md +243 -0
  72. data/doc/user_guide_section_6.md +147 -0
  73. data/doc/user_guide_section_7.md +311 -0
  74. data/doc/user_guide_section_8.md +224 -0
  75. data/doc/user_guide_section_9.md +375 -0
  76. data/doc/userdoc-toc.txt +88 -0
  77. data/lib/rouge/lexers/livetext.rb +74 -0
  78. data/lib/scriptorium/api.rb +640 -0
  79. data/lib/scriptorium/banner_svg.rb +742 -0
  80. data/lib/scriptorium/contract.rb +33 -0
  81. data/lib/scriptorium/exceptions.rb +174 -0
  82. data/lib/scriptorium/helpers.rb +475 -0
  83. data/lib/scriptorium/post.rb +195 -0
  84. data/lib/scriptorium/reddit.rb +83 -0
  85. data/lib/scriptorium/repo.rb +624 -0
  86. data/lib/scriptorium/standard_files.rb +515 -0
  87. data/lib/scriptorium/syntax_highlighter.rb +234 -0
  88. data/lib/scriptorium/theme.rb +179 -0
  89. data/lib/scriptorium/version.rb +2 -2
  90. data/lib/scriptorium/view.rb +976 -0
  91. data/lib/scriptorium/widgets/featured_posts.rb +149 -0
  92. data/lib/scriptorium/widgets/links.rb +112 -0
  93. data/lib/scriptorium/widgets/pages.rb +133 -0
  94. data/lib/scriptorium/widgets/widget.rb +133 -0
  95. data/lib/scriptorium.rb +22 -9
  96. data/lib/skeleton.rb +11 -2
  97. data/scriptorium.gemspec +15 -4
  98. data/test/README.md +69 -0
  99. data/test/all +43 -0
  100. data/test/api_demo.rb +99 -0
  101. data/test/assets/imagenotfound.jpg +0 -0
  102. data/test/assets/images/.DS_Store +0 -0
  103. data/test/assets/images/README.md +27 -0
  104. data/test/assets/images/odd_aspect.png +0 -0
  105. data/test/assets/images/perfect.png +0 -0
  106. data/test/assets/images/small.png +0 -0
  107. data/test/assets/images/tall.png +0 -0
  108. data/test/assets/images/very_tall.png +0 -0
  109. data/test/assets/images/very_wide.png +0 -0
  110. data/test/assets/images/wide.png +0 -0
  111. data/test/assets/testbanner.jpg +0 -0
  112. data/test/banner_svg/simple_helpers.rb +13 -0
  113. data/test/banner_svg/unit.rb +768 -0
  114. data/test/ed_test.rb +204 -0
  115. data/test/integration/cursor_banner_combinations.rb +193 -0
  116. data/test/integration/cursor_banner_features.rb +374 -0
  117. data/test/integration/integration_test.rb +326 -0
  118. data/test/livetext_plugin_test.rb +229 -0
  119. data/test/manual/asset_mgmt.rb +67 -0
  120. data/test/manual/banner-tests/config.txt +3 -0
  121. data/test/manual/banner-tests/index.html +45 -0
  122. data/test/manual/banner-tests/test01.html +58 -0
  123. data/test/manual/banner-tests/test02.html +58 -0
  124. data/test/manual/banner-tests/test03.html +58 -0
  125. data/test/manual/banner-tests/test04.html +65 -0
  126. data/test/manual/banner-tests/test05.html +65 -0
  127. data/test/manual/banner-tests/test06.html +65 -0
  128. data/test/manual/banner-tests/test07.html +65 -0
  129. data/test/manual/banner-tests/test08.html +59 -0
  130. data/test/manual/banner-tests/test09.html +59 -0
  131. data/test/manual/banner-tests/test10.html +59 -0
  132. data/test/manual/banner-tests/test11.html +59 -0
  133. data/test/manual/banner-tests/test12.html +59 -0
  134. data/test/manual/banner-tests/test13.html +59 -0
  135. data/test/manual/banner-tests/test14.html +59 -0
  136. data/test/manual/banner-tests/test15.html +58 -0
  137. data/test/manual/banner-tests/test16.html +58 -0
  138. data/test/manual/banner-tests/test17.html +58 -0
  139. data/test/manual/banner-tests/test18.html +68 -0
  140. data/test/manual/banner-tests/test19.html +68 -0
  141. data/test/manual/banner-tests/test20.html +68 -0
  142. data/test/manual/banner-tests/test21.html +68 -0
  143. data/test/manual/banner-tests/test22.html +68 -0
  144. data/test/manual/banner-tests/test23.html +68 -0
  145. data/test/manual/banner-tests/test24.html +68 -0
  146. data/test/manual/banner-tests/test25.html +67 -0
  147. data/test/manual/banner_environment.rb +192 -0
  148. data/test/manual/deploy_symlink_demo.rb +142 -0
  149. data/test/manual/environment.rb +67 -0
  150. data/test/manual/make_banner.rb +153 -0
  151. data/test/manual/sample_banner_config.txt +12 -0
  152. data/test/manual/symlink_demo.rb +117 -0
  153. data/test/manual/test1.rb +47 -0
  154. data/test/manual/test2.rb +12 -0
  155. data/test/manual/test3.rb +38 -0
  156. data/test/manual/test4.rb +40 -0
  157. data/test/manual/test5.rb +24 -0
  158. data/test/manual/test6.rb +73 -0
  159. data/test/manual/test_banner_combinations.rb +120 -0
  160. data/test/manual/test_banner_features.rb +306 -0
  161. data/test/manual/test_banner_from_file.rb +150 -0
  162. data/test/manual/test_banner_in_header.rb +35 -0
  163. data/test/manual/test_code_highlighting.rb +68 -0
  164. data/test/manual/test_complex_header.rb +74 -0
  165. data/test/manual/test_empty_header.rb +32 -0
  166. data/test/manual/test_radial_custom.rb +58 -0
  167. data/test/manual/test_radial_large_radius.rb +52 -0
  168. data/test/manual/test_svg_debug.rb +47 -0
  169. data/test/manual/test_syntax_highlighting.rb +147 -0
  170. data/test/pages-demo/config/currentview.txt +1 -0
  171. data/test/pages-demo/views/demo/config/bootstrap_css.txt +5 -0
  172. data/test/pages-demo/views/demo/config/bootstrap_js.txt +4 -0
  173. data/test/pages-demo/views/demo/config/common.js +57 -0
  174. data/test/pages-demo/views/demo/config/footer.txt +1 -0
  175. data/test/pages-demo/views/demo/config/global-head.txt +8 -0
  176. data/test/pages-demo/views/demo/config/header.txt +1 -0
  177. data/test/pages-demo/views/demo/config/layout.txt +1 -0
  178. data/test/pages-demo/views/demo/config/left.txt +1 -0
  179. data/test/pages-demo/views/demo/config/main.txt +1 -0
  180. data/test/pages-demo/views/demo/config/right.txt +1 -0
  181. data/test/pages-demo/views/demo/config.txt +3 -0
  182. data/test/pages-demo/views/demo/output/panes/footer.html +1 -0
  183. data/test/pages-demo/views/demo/output/panes/header.html +1 -0
  184. data/test/pages-demo/views/demo/output/panes/left.html +1 -0
  185. data/test/pages-demo/views/demo/output/panes/main.html +1 -0
  186. data/test/pages-demo/views/demo/output/panes/right.html +1 -0
  187. data/test/rubytext/rubytext_comprehensive_test.rb +307 -0
  188. data/test/rubytext/rubytext_demo_test.rb +42 -0
  189. data/test/rubytext/rubytext_testing_guide.md +277 -0
  190. data/test/run_automated_tests.rb +45 -0
  191. data/test/scriptorium-TEST-1754622690-146/config/bootstrap_css.txt +5 -0
  192. data/test/scriptorium-TEST-1754622690-146/config/bootstrap_js.txt +4 -0
  193. data/test/scriptorium-TEST-1754622690-146/config/common.js +57 -0
  194. data/test/scriptorium-TEST-1754622690-146/config/currentview.txt +1 -0
  195. data/test/scriptorium-TEST-1754622690-146/config/global-head.txt +9 -0
  196. data/test/scriptorium-TEST-1754622690-146/config/last_post_num.txt +1 -0
  197. data/test/scriptorium-TEST-1754622690-146/config/os_helpers.rb +4 -0
  198. data/test/scriptorium-TEST-1754622690-146/config/widgets.txt +3 -0
  199. data/test/scriptorium-TEST-1754622690-146/posts/0001/meta.txt +8 -0
  200. data/test/scriptorium-TEST-1754622690-146/posts/0001/source.lt3 +6 -0
  201. data/test/scriptorium-TEST-1754622690-146/themes/standard/README.txt +1 -0
  202. data/test/scriptorium-TEST-1754622690-146/themes/standard/config.txt +1 -0
  203. data/test/scriptorium-TEST-1754622690-146/themes/standard/initial/post.lt3 +12 -0
  204. data/test/scriptorium-TEST-1754622690-146/themes/standard/layout/config/footer.txt +2 -0
  205. data/test/scriptorium-TEST-1754622690-146/themes/standard/layout/config/header.txt +4 -0
  206. data/test/scriptorium-TEST-1754622690-146/themes/standard/layout/config/left.txt +3 -0
  207. data/test/scriptorium-TEST-1754622690-146/themes/standard/layout/config/main.txt +5 -0
  208. data/test/scriptorium-TEST-1754622690-146/themes/standard/layout/config/right.txt +3 -0
  209. data/test/scriptorium-TEST-1754622690-146/themes/standard/layout/gen/text.css +1 -0
  210. data/test/scriptorium-TEST-1754622690-146/themes/standard/layout/layout.txt +5 -0
  211. data/test/scriptorium-TEST-1754622690-146/themes/standard/templates/index.lt3 +1 -0
  212. data/test/scriptorium-TEST-1754622690-146/themes/standard/templates/index_entry.lt3 +14 -0
  213. data/test/scriptorium-TEST-1754622690-146/themes/standard/templates/post.lt3 +13 -0
  214. data/test/scriptorium-TEST-1754622690-146/themes/standard/templates/widget.lt3 +1 -0
  215. data/test/scriptorium-TEST-1754622690-146/views/sample/config/bootstrap_css.txt +5 -0
  216. data/test/scriptorium-TEST-1754622690-146/views/sample/config/bootstrap_js.txt +4 -0
  217. data/test/scriptorium-TEST-1754622690-146/views/sample/config/common.js +57 -0
  218. data/test/scriptorium-TEST-1754622690-146/views/sample/config/deploy.txt +5 -0
  219. data/test/scriptorium-TEST-1754622690-146/views/sample/config/footer.txt +2 -0
  220. data/test/scriptorium-TEST-1754622690-146/views/sample/config/global-head.txt +9 -0
  221. data/test/scriptorium-TEST-1754622690-146/views/sample/config/header.txt +4 -0
  222. data/test/scriptorium-TEST-1754622690-146/views/sample/config/layout.txt +5 -0
  223. data/test/scriptorium-TEST-1754622690-146/views/sample/config/left.txt +3 -0
  224. data/test/scriptorium-TEST-1754622690-146/views/sample/config/main.txt +5 -0
  225. data/test/scriptorium-TEST-1754622690-146/views/sample/config/reddit.txt +10 -0
  226. data/test/scriptorium-TEST-1754622690-146/views/sample/config/right.txt +3 -0
  227. data/test/scriptorium-TEST-1754622690-146/views/sample/config/social.txt +7 -0
  228. data/test/scriptorium-TEST-1754622690-146/views/sample/config/status.txt +7 -0
  229. data/test/scriptorium-TEST-1754622690-146/views/sample/config.txt +3 -0
  230. data/test/scriptorium-TEST-1754622690-146/views/sample/layout/footer.html +3 -0
  231. data/test/scriptorium-TEST-1754622690-146/views/sample/layout/header.html +3 -0
  232. data/test/scriptorium-TEST-1754622690-146/views/sample/layout/left.html +3 -0
  233. data/test/scriptorium-TEST-1754622690-146/views/sample/layout/main.html +3 -0
  234. data/test/scriptorium-TEST-1754622690-146/views/sample/layout/right.html +3 -0
  235. data/test/scriptorium-TEST-1754622690-146/views/sample/output/panes/footer.html +1 -0
  236. data/test/scriptorium-TEST-1754622690-146/views/sample/output/panes/header.html +1 -0
  237. data/test/scriptorium-TEST-1754622690-146/views/sample/output/panes/left.html +1 -0
  238. data/test/scriptorium-TEST-1754622690-146/views/sample/output/panes/main.html +1 -0
  239. data/test/scriptorium-TEST-1754622690-146/views/sample/output/panes/right.html +1 -0
  240. data/test/staging/.DS_Store +0 -0
  241. data/test/syntax_highlighting_test.lt3 +124 -0
  242. data/test/test_helpers.rb +230 -0
  243. data/test/tui_editor_integration_test.rb +296 -0
  244. data/test/tui_integration_test.rb +637 -0
  245. data/test/unit/api.rb +1056 -0
  246. data/test/unit/asset_management.rb +245 -0
  247. data/test/unit/clipboard_test.rb +60 -0
  248. data/test/unit/contract_test.rb +91 -0
  249. data/test/unit/core.rb +857 -0
  250. data/test/unit/deploy_test.rb +187 -0
  251. data/test/unit/gem_asset_management.rb +189 -0
  252. data/test/unit/livetext_basic.rb +69 -0
  253. data/test/unit/livetext_compatibility.rb +89 -0
  254. data/test/unit/post.rb +244 -0
  255. data/test/unit/read_commented_file_test.rb +276 -0
  256. data/test/unit/reddit_test.rb +235 -0
  257. data/test/unit/repo.rb +548 -0
  258. data/test/unit/social_test.rb +369 -0
  259. data/test/unit/symlink_test.rb +213 -0
  260. data/test/unit/view.rb +431 -0
  261. data/test/unit/widgets.rb +669 -0
  262. data/test/wizard_test.rb +123 -0
  263. data/ui/README.md +67 -0
  264. data/ui/common/lib/ui_common.rb +8 -0
  265. data/ui/rubytext/README.md +191 -0
  266. data/ui/rubytext/bin/scriptorium-rubytext +402 -0
  267. data/ui/rubytext/lib/rubytext_ui.rb +300 -0
  268. data/ui/tui/bin/scriptorium +1420 -0
  269. data/ui/tui/test/tui_test.rb +23 -0
  270. data/ui/web/app/app.rb +1378 -0
  271. data/ui/web/app/error_helpers.rb +150 -0
  272. data/ui/web/app/views/advanced_config.erb +190 -0
  273. data/ui/web/app/views/asset_management.erb +589 -0
  274. data/ui/web/app/views/banner_config.erb +200 -0
  275. data/ui/web/app/views/configure_view.erb +401 -0
  276. data/ui/web/app/views/dashboard.erb +162 -0
  277. data/ui/web/app/views/deploy_config.erb +146 -0
  278. data/ui/web/app/views/edit_pages.erb +195 -0
  279. data/ui/web/app/views/edit_post.erb +54 -0
  280. data/ui/web/app/views/error_page.erb +29 -0
  281. data/ui/web/app/views/header_config.erb +155 -0
  282. data/ui/web/app/views/layout_config.erb +147 -0
  283. data/ui/web/app/views/navbar_config.erb +411 -0
  284. data/ui/web/app/views/view_dashboard.erb +138 -0
  285. data/ui/web/bin/scriptorium-web +153 -0
  286. data/ui/web/test/web_basic_test.rb +38 -0
  287. data/ui/web/test_navbar.txt +7 -0
  288. data/ui/web/tmp/web_server.log +5 -0
  289. data/ui/web/tmp/web_server.pid +1 -0
  290. metadata +360 -5
@@ -0,0 +1,33 @@
1
+ module Scriptorium::Contract
2
+ def self.enabled?
3
+ !ENV['DBC_DISABLED']
4
+ end
5
+
6
+ def assume(condition = nil, message = nil, &block)
7
+ return unless Scriptorium::Contract.enabled?
8
+ if block_given?
9
+ raise "Precondition violated: #{message}" unless instance_eval(&block)
10
+ else
11
+ raise "Precondition violated: #{message}" unless condition
12
+ end
13
+ end
14
+
15
+ def verify(condition = nil, message = nil, &block)
16
+ return unless Scriptorium::Contract.enabled?
17
+ if block_given?
18
+ raise "Postcondition violated: #{message}" unless instance_eval(&block)
19
+ else
20
+ raise "Postcondition violated: #{message}" unless condition
21
+ end
22
+ end
23
+
24
+ def invariant(&block)
25
+ @invariants ||= []
26
+ @invariants << block
27
+ end
28
+
29
+ def check_invariants
30
+ return unless Scriptorium::Contract.enabled?
31
+ @invariants&.each { |invariant| raise "Invariant violated" unless instance_eval(&invariant) }
32
+ end
33
+ end
@@ -0,0 +1,174 @@
1
+ module Scriptorium::Exceptions
2
+
3
+ def make_exception(sym, str, target_class = Object)
4
+ return if target_class.constants.include?(sym)
5
+ klass = sym # :"#{sym}_Class"
6
+ target_class.const_set(klass, StandardError)
7
+ define_method(sym) do |*args|
8
+ args = [] unless args.first
9
+ msg = str.dup
10
+ args.each.with_index {|arg, i| msg.sub!("%#{i+1}", arg.to_s) }
11
+ target_class.class_eval(klass.to_s).new(msg)
12
+ end
13
+ end
14
+
15
+ make_exception :TestModeOnly, "Test mode only"
16
+ make_exception :ViewDirAlreadyExists, "View directory already exists: %1"
17
+ make_exception :RepoDirAlreadyExists, "Repository directory already exists: %1"
18
+ make_exception :ViewDirDoesntExist, "View directory doesn't exist: %1"
19
+ make_exception :MoreThanOneResult, "More than one result found for: %1"
20
+ make_exception :CannotLookupView, "Cannot lookup view: %1"
21
+ make_exception :ThemeDoesntExist, "Theme doesn't exist: %1"
22
+ make_exception :ThemeFileNotFound, "Theme file not found: %1"
23
+ make_exception :LayoutHasUnknownTag, "Layout has unknown tag: %1"
24
+ make_exception :LayoutHasDuplicateTags, "Layout has duplicate tags: %1"
25
+ make_exception :LayoutFileMissing, "Layout file missing: %1"
26
+ make_exception :AssetNotFound, "Asset not found: %1"
27
+ make_exception :NoGemPath, "No gem path"
28
+
29
+ # Validation errors
30
+ make_exception :NilValueError, "Value is nil: %1"
31
+ make_exception :EmptyValueError, "Value is empty or whitespace-only: %1"
32
+ make_exception :InvalidFormatError, "Invalid format '%1': %2"
33
+
34
+ # Specific validation errors
35
+ make_exception :CannotCreateViewNameNil, "Cannot create view: name is nil"
36
+ make_exception :CannotCreateViewNameEmpty, "Cannot create view: name is empty or whitespace-only"
37
+ make_exception :CannotCreateViewNameInvalid, "Cannot create view: invalid name '%1' (only alphanumeric, hyphen, and underscore allowed)"
38
+ make_exception :CannotCreateViewTitleNil, "Cannot create view: title is nil"
39
+ make_exception :CannotCreateViewTitleEmpty, "Cannot create view: title is empty or whitespace-only"
40
+
41
+ make_exception :CannotLookupViewTargetNil, "Cannot lookup view: target is nil"
42
+ make_exception :CannotLookupViewTargetEmpty, "Cannot lookup view: target is empty or whitespace-only"
43
+
44
+ make_exception :CannotGetPostIdNil, "Cannot get post: id is nil"
45
+ make_exception :CannotGetPostIdEmpty, "Cannot get post: id is empty or whitespace-only"
46
+ make_exception :CannotGetPostIdInvalid, "Cannot get post: invalid id '%1' (must be numeric)"
47
+
48
+ make_exception :CannotCreatePostRepoNil, "Cannot create post: repo is nil"
49
+ make_exception :CannotCreatePostNumNil, "Cannot create post: num is nil"
50
+ make_exception :CannotCreatePostNumEmpty, "Cannot create post: num is empty or whitespace-only"
51
+ make_exception :CannotCreatePostNumInvalid, "Cannot create post: invalid num '%1' (must be numeric)"
52
+
53
+ make_exception :CannotSetPubdateYmdNil, "Cannot set pubdate: ymd is nil"
54
+ make_exception :CannotSetPubdateYmdEmpty, "Cannot set pubdate: ymd is empty or whitespace-only"
55
+ make_exception :CannotSetPubdateInvalidFormat, "Cannot set pubdate: invalid date format '%1' (expected YYYY-MM-DD)"
56
+
57
+ make_exception :CannotBuildWidgetsArgNil, "Cannot build widgets: argument is nil"
58
+ make_exception :CannotBuildWidgetsArgEmpty, "Cannot build widgets: argument is empty or whitespace-only"
59
+ make_exception :CannotBuildWidgetNameNil, "Cannot build widget: widget name is nil or empty"
60
+ make_exception :CannotBuildWidgetNameInvalid, "Cannot build widget: invalid widget name '%1' (only alphanumeric and underscore allowed)"
61
+
62
+ # File/IO errors
63
+ make_exception :FileNotFoundError, "File not found: %1"
64
+ make_exception :PermissionDeniedError, "Permission denied: %1"
65
+ make_exception :DiskFullError, "Disk full: %1"
66
+ make_exception :DirectoryNotFoundError, "Directory not found: %1"
67
+
68
+ # Specific file/IO errors
69
+ make_exception :CannotWriteFilePathNil, "Cannot write file: file path is nil"
70
+ make_exception :CannotWriteFilePathEmpty, "Cannot write file: file path is empty or whitespace-only"
71
+ make_exception :CannotWriteFilePermissionDenied, "Cannot write file %1: permission denied (%2)"
72
+ make_exception :CannotWriteFileDiskFull, "Cannot write file %1: disk full (%2)"
73
+ make_exception :CannotWriteFileDirectoryNotFound, "Cannot write file %1: directory not found (%2)"
74
+ make_exception :CannotWriteFileError, "Cannot write file %1: %2"
75
+
76
+ make_exception :CannotCreateDirectoryPathNil, "Cannot create directory: directory path is nil"
77
+ make_exception :CannotCreateDirectoryPathEmpty, "Cannot create directory: directory path is empty or whitespace-only"
78
+ make_exception :CannotCreateDirectoryPermissionDenied, "Cannot create directory %1: permission denied (%2)"
79
+ make_exception :CannotCreateDirectoryParentNotFound, "Cannot create directory %1: parent directory not found (%2)"
80
+ make_exception :CannotCreateDirectoryDiskFull, "Cannot create directory %1: disk full (%2)"
81
+ make_exception :CannotCreateDirectoryError, "Cannot create directory %1: %2"
82
+
83
+ make_exception :CannotReadFilePathNil, "Cannot read file: file path is nil"
84
+ make_exception :CannotReadFilePathEmpty, "Cannot read file: file path is empty or whitespace-only"
85
+ make_exception :CannotReadFileNotFound, "Cannot read file %1: file not found (%2)"
86
+ make_exception :CannotReadFilePermissionDenied, "Cannot read file %1: permission denied (%2)"
87
+ make_exception :CannotReadFileError, "Cannot read file %1: %2"
88
+
89
+ make_exception :CannotEditFilePathNil, "Cannot edit file: file path is nil"
90
+ make_exception :CannotEditFilePathEmpty, "Cannot edit file: file path is empty or whitespace-only"
91
+
92
+ make_exception :CannotRequirePathNil, "Cannot require %1: path is nil"
93
+ make_exception :CannotRequirePathEmpty, "Cannot require %1: path is empty or whitespace-only"
94
+ make_exception :RequiredFileNotFound, "Required %1 not found: %2"
95
+ make_exception :InvalidType, "Invalid type: %1 (must be :file or :dir)"
96
+
97
+ # View errors
98
+ make_exception :CannotCreateView, "Cannot create view: %1"
99
+ make_exception :CannotBuildWidget, "Cannot build widget: %1"
100
+
101
+ # Post errors
102
+ make_exception :CannotCreatePost, "Cannot create post: %1"
103
+ make_exception :CannotGetPost, "Cannot get post: %1"
104
+ make_exception :CannotSetPubdate, "Cannot set pubdate: %1"
105
+
106
+ # Banner SVG errors
107
+ make_exception :CannotHandleBackground, "Cannot handle background: %1"
108
+ make_exception :CannotHandleGradient, "Cannot handle gradient: %1"
109
+ make_exception :CannotHandleImage, "Cannot handle image: %1"
110
+ make_exception :CannotHandleAspect, "Cannot handle aspect: %1"
111
+ make_exception :CannotHandleFont, "Cannot handle font: %1"
112
+ make_exception :CannotHandleColor, "Cannot handle color: %1"
113
+ make_exception :CannotHandleAlign, "Cannot handle align: %1"
114
+ make_exception :CannotHandleXY, "Cannot handle xy: %1"
115
+
116
+ # Specific Banner SVG errors
117
+ make_exception :CannotHandleBackgroundNoArgs, "Cannot handle background: no arguments provided"
118
+ make_exception :CannotHandleBackgroundFirstArgNil, "Cannot handle background: first argument is nil"
119
+ make_exception :CannotHandleBackgroundFirstArgEmpty, "Cannot handle background: first argument is empty or whitespace-only"
120
+
121
+ make_exception :CannotHandleLinearGradientNoArgs, "Cannot handle linear gradient: no arguments provided"
122
+ make_exception :CannotHandleLinearGradientStartColorNil, "Cannot handle linear gradient: start color is nil or empty"
123
+ make_exception :CannotHandleLinearGradientArgEmpty, "Cannot handle linear gradient: argument %1 is empty or whitespace-only"
124
+
125
+ make_exception :CannotHandleRadialGradientNoArgs, "Cannot handle radial gradient: no arguments provided"
126
+ make_exception :CannotHandleRadialGradientStartColorNil, "Cannot handle radial gradient: start color is nil or empty"
127
+ make_exception :CannotHandleRadialGradientArgEmpty, "Cannot handle radial gradient: argument %1 is empty or whitespace-only"
128
+
129
+ make_exception :CannotHandleImageBackgroundNoArgs, "Cannot handle image background: no arguments provided"
130
+ make_exception :CannotHandleImageBackgroundFirstArgNil, "Cannot handle image background: first argument is nil"
131
+ make_exception :CannotHandleImageBackgroundFirstArgEmpty, "Cannot handle image background: first argument is empty or whitespace-only"
132
+
133
+ make_exception :CannotHandleAspectNoArgs, "Cannot handle aspect: no arguments provided"
134
+ make_exception :CannotHandleAspectFirstArgNil, "Cannot handle aspect: first argument is nil"
135
+ make_exception :CannotHandleAspectFirstArgEmpty, "Cannot handle aspect: first argument is empty or whitespace-only"
136
+ make_exception :CannotHandleAspectInvalidValue, "Cannot handle aspect: invalid aspect value '%1' (must be numeric)"
137
+
138
+ make_exception :CannotHandleFontArgsNil, "Cannot handle font: arguments are nil"
139
+ make_exception :CannotHandleFontArgNil, "Cannot handle font: argument %1 is nil"
140
+ make_exception :CannotHandleFontArgEmpty, "Cannot handle font: argument %1 is empty or whitespace-only"
141
+
142
+ make_exception :CannotHandleTextColorNoArgs, "Cannot handle text color: no arguments provided"
143
+ make_exception :CannotHandleTextColorFirstArgNil, "Cannot handle text color: first argument is nil"
144
+ make_exception :CannotHandleTextColorFirstArgEmpty, "Cannot handle text color: first argument is empty or whitespace-only"
145
+
146
+ make_exception :CannotHandleXYWhichNil, "Cannot handle xy: which is nil"
147
+ make_exception :CannotHandleXYWhichEmpty, "Cannot handle xy: which is empty or whitespace-only"
148
+ make_exception :CannotHandleXYInvalidWhich, "Cannot handle xy: invalid which '%1' (must be 'title' or 'subtitle')"
149
+
150
+ make_exception :CannotHandleAlignNoArgs, "Cannot handle align: no arguments provided"
151
+ make_exception :CannotHandleAlignDirectionNil, "Cannot handle align: direction is nil or empty"
152
+ make_exception :CannotHandleAlignInvalidDirection, "Cannot handle align: invalid direction '%1' (must be 'left', 'center', or 'right')"
153
+ make_exception :CannotHandleAlignArgEmpty, "Cannot handle align: argument %1 is empty or whitespace-only"
154
+
155
+ make_exception :CannotHandleColorNoArgs, "Cannot handle color: no arguments provided"
156
+ make_exception :CannotHandleColorFirstArgNil, "Cannot handle color: first argument is nil"
157
+ make_exception :CannotHandleColorFirstArgEmpty, "Cannot handle color: first argument is empty or whitespace-only"
158
+
159
+
160
+
161
+ # Command errors
162
+ make_exception :CommandFailed, "Command failed: %1"
163
+ make_exception :CannotExecuteCommand, "Cannot execute command: %1"
164
+
165
+ # Specific command errors
166
+ make_exception :CannotExecuteCommandNil, "Cannot execute command: command is nil"
167
+ make_exception :CannotExecuteCommandEmpty, "Cannot execute command: command is empty or whitespace-only"
168
+ make_exception :CommandFailedWithDesc, "Command failed%1: %2"
169
+
170
+ # Section/Output errors
171
+ make_exception :SectionOutputError, "Section output error: %1 (section: %2) - %3"
172
+ make_exception :FailedToWriteFrontPage, "Failed to write front page: %1"
173
+
174
+ end
@@ -0,0 +1,475 @@
1
+ # Path magic
2
+
3
+ module PathSep
4
+ def /(right)
5
+ s1 = self.to_s.dup
6
+ s2 = right.to_s.dup
7
+ s1 << "/" unless s1.end_with?("/") || s2.start_with?("/")
8
+ path = s1 + s2
9
+ path.gsub!("//", "/")
10
+ path
11
+ end
12
+ end
13
+
14
+ String.include(PathSep)
15
+ Symbol.include(PathSep)
16
+
17
+ ## Helpers
18
+
19
+ module Scriptorium::Helpers
20
+ include Scriptorium::Exceptions
21
+ def getvars(file)
22
+ lines = read_file(file, lines: true)
23
+ lines.map! {|line| line.sub(/# .*$/, "").strip }
24
+ lines.reject! {|line| line.empty? }
25
+ vhash = Hash.new("")
26
+ lines.each do |line|
27
+ var, val = line.split(" ", 2)
28
+ vhash[var.to_sym] = val
29
+ end
30
+ vhash
31
+ end
32
+
33
+ def d4(num)
34
+ "%04d" % num
35
+ end
36
+
37
+ def view_dir(name)
38
+ @root/:views/name
39
+ end
40
+
41
+ def write_file(file, content)
42
+ # Input validation
43
+ raise CannotWriteFilePathNil if file.nil?
44
+
45
+ raise CannotWriteFilePathEmpty if file.to_s.strip.empty?
46
+
47
+ # Ensure parent directory exists
48
+ FileUtils.mkdir_p(File.dirname(file))
49
+
50
+ # Write the file with error handling
51
+ begin
52
+ File.open(file, "w") do |f|
53
+ f.puts content
54
+ end
55
+ rescue Errno::ENOSPC => e
56
+ raise CannotWriteFileDiskFull(file, e.message)
57
+ rescue Errno::EACCES => e
58
+ raise CannotWriteFilePermissionDenied(file, e.message)
59
+ rescue Errno::ENOENT => e
60
+ raise CannotWriteFileDirectoryNotFound(file, e.message)
61
+ rescue => e
62
+ raise CannotWriteFileError(file, e.message)
63
+ end
64
+ end
65
+
66
+ def write_file!(file, *lines)
67
+ # Convert nil values to empty strings for proper joining
68
+ processed_lines = lines.map { |line| line.nil? ? "" : line.to_s }
69
+ content = processed_lines.join("\n")
70
+ # Always add a newline at the end to ensure there's an empty line
71
+ content += "\n"
72
+ write_file(file, content)
73
+ end
74
+
75
+ def make_dir(dir, create_parents = false)
76
+ # Input validation
77
+ raise CannotCreateDirectoryPathNil if dir.nil?
78
+
79
+ raise CannotCreateDirectoryPathEmpty if dir.to_s.strip.empty?
80
+
81
+ # Create parent directories if requested
82
+ if create_parents
83
+ FileUtils.mkdir_p(dir)
84
+ else
85
+ # Create single directory with error handling
86
+ begin
87
+ Dir.mkdir(dir)
88
+ rescue Errno::ENOSPC => e
89
+ raise CannotCreateDirectoryDiskFull(dir, e.message)
90
+ rescue Errno::EACCES => e
91
+ raise CannotCreateDirectoryPermissionDenied(dir, e.message)
92
+ rescue Errno::ENOENT => e
93
+ raise CannotCreateDirectoryParentNotFound(dir, e.message)
94
+ rescue Errno::EEXIST => e
95
+ # Directory already exists - this is usually not an error
96
+ # But we could make this configurable if needed
97
+ rescue => e
98
+ raise CannotCreateDirectoryError(dir, e.message)
99
+ end
100
+ end
101
+ end
102
+
103
+ def system!(command, description = nil)
104
+ # Input validation
105
+ raise CannotExecuteCommandNil if command.nil?
106
+
107
+ raise CannotExecuteCommandEmpty if command.to_s.strip.empty?
108
+
109
+ # Execute command with error handling
110
+ success = system(command)
111
+
112
+ unless success
113
+ desc = description ? " (#{description})" : ""
114
+ raise CommandFailedWithDesc(desc, command)
115
+ end
116
+
117
+ success
118
+ end
119
+
120
+ def need(type, path, exception_class = RuntimeError)
121
+ # Input validation
122
+ raise CannotRequirePathNil(type) if path.nil?
123
+
124
+ raise CannotRequirePathEmpty(type) if path.to_s.strip.empty?
125
+
126
+ # Check if file/directory exists
127
+ exists = case type
128
+ when :file
129
+ File.exist?(path)
130
+ when :dir
131
+ Dir.exist?(path)
132
+ else
133
+ raise InvalidType(type)
134
+ end
135
+
136
+ unless exists
137
+ raise RequiredFileNotFound(type, path) if exception_class == RuntimeError
138
+
139
+ # Exception class - try to call it as a method first, then as constructor
140
+ raise exception_class.call(path) if exception_class.respond_to?(:call)
141
+ raise exception_class.new(path)
142
+ end
143
+
144
+ path
145
+ end
146
+
147
+ def read_file(file, options = {})
148
+ # Input validation
149
+ raise CannotReadFilePathNil if file.nil?
150
+
151
+ raise CannotReadFilePathEmpty if file.to_s.strip.empty?
152
+
153
+ # Handle missing file with fallback
154
+ if options[:missing_fallback]
155
+ return options[:missing_fallback] unless File.exist?(file)
156
+ end
157
+
158
+ # Read the file with error handling
159
+ begin
160
+ if options[:lines]
161
+ # Read as lines
162
+ if options[:chomp]
163
+ File.readlines(file, chomp: true)
164
+ else
165
+ File.readlines(file)
166
+ end
167
+ else
168
+ # Read as content
169
+ File.read(file)
170
+ end
171
+ rescue Errno::ENOENT => e
172
+ if options[:missing_fallback]
173
+ return options[:missing_fallback]
174
+ else
175
+ raise CannotReadFileNotFound(file, e.message)
176
+ end
177
+ rescue Errno::EACCES => e
178
+ raise CannotReadFilePermissionDenied(file, e.message)
179
+ rescue => e
180
+ raise CannotReadFileError(file, e.message)
181
+ end
182
+ end
183
+
184
+
185
+
186
+ def change_config(file_path, target_key, new_value)
187
+ pattern = /
188
+ ^(?<leading>\s*#{Regexp.escape(target_key)}\s+) # key and spacing
189
+ (?<old_value>[^\#]*?) # value (non-greedy up to comment)
190
+ (?<trailing>\s*) # trailing space
191
+ (?<comment>\#.*)?$ # optional comment
192
+ /x
193
+
194
+ lines = read_file(file_path, lines: true)
195
+ updated_lines = lines.map do |line|
196
+ if match = pattern.match(line)
197
+ leading = match[:leading]
198
+ trailing = match[:trailing]
199
+ comment = match[:comment] || ''
200
+ "#{leading}#{new_value}#{trailing}#{comment}\n"
201
+ else
202
+ line
203
+ end
204
+ end
205
+
206
+ write_file(file_path, updated_lines.join)
207
+ end
208
+
209
+ def slugify(id, title)
210
+ slug = title.downcase.strip
211
+ .gsub(/[^a-z0-9\s_-]/, '') # remove punctuation
212
+ .gsub(/[\s_-]+/, '-') # replace spaces and underscores with hyphen
213
+ .gsub(/^-+|-+$/, '') # trim leading/trailing hyphens
214
+ "#{d4(id)}-#{slug}"
215
+ end
216
+
217
+ def clean_slugify(title)
218
+ return "title-is-missing" if title.nil?
219
+
220
+ slug = title.downcase.strip
221
+ .gsub(/[^a-z0-9\s_-]/, '') # remove punctuation
222
+ .gsub(/[\s_-]+/, '-') # replace spaces and underscores with hyphen
223
+ .gsub(/^-+|-+$/, '') # trim leading/trailing hyphens
224
+ slug
225
+ end
226
+
227
+ def ymdhms
228
+ Time.now.strftime("%Y-%m-%d-%H-%M-%S")
229
+ end
230
+
231
+ def see_file(file) # Really from TestHelpers
232
+ puts "----- File: #{file}"
233
+ system!("cat #{file}", "displaying file contents")
234
+ puts "-----"
235
+ end
236
+
237
+ def see(label, var)
238
+ puts "#{label} = \n<<<\n#{var}\n>>>"
239
+ end
240
+
241
+ def make_tree(base, text)
242
+ lines = text.split("\n").map(&:chomp)
243
+ lines.each {|line| line.gsub!(/ *#.*$/, "") }
244
+ entries = []
245
+
246
+ # Determine the root name
247
+ first_line = lines.shift
248
+ root = first_line.strip.sub(/\/$/, "") # remove trailing slash
249
+ root_path = File.join(base, root)
250
+ make_dir(root_path) unless File.exist?(root_path)
251
+
252
+ # Prepare stack starting from root
253
+ stack = [root_path]
254
+
255
+ # Parse the remaining lines
256
+ lines.each do |line|
257
+ if (i = line.index(/ [a-zA-Z0-9_.]/))
258
+ name = line[(i + 1)..-1]
259
+ level = i / 4
260
+ else
261
+ name = line.strip
262
+ level = 0
263
+ end
264
+ entries << [level, name]
265
+ end
266
+
267
+ entries.each do |level, name|
268
+ stack = stack[0..level]
269
+ full_path = File.join(stack.last, name)
270
+
271
+ if name.end_with?("/")
272
+ make_dir(full_path) unless File.exist?(full_path)
273
+ stack << full_path
274
+ else
275
+ write_file(full_path, "Empty file generated at #{Time.now}")
276
+ end
277
+ end
278
+ end
279
+
280
+ def substitute(obj, text)
281
+ vars = obj.is_a?(Hash) ? obj : obj.vars
282
+ text % vars
283
+ end
284
+
285
+ def escape_html(str)
286
+ str.gsub(/&/, '&amp;')
287
+ .gsub(/</, '&lt;')
288
+ .gsub(/>/, '&gt;')
289
+ .gsub(/"/, '&quot;')
290
+ .gsub(/'/, '&#39;')
291
+ end
292
+
293
+ def read_commented_file(file_path)
294
+ return [] unless File.exist?(file_path)
295
+ lines = read_file(file_path, lines: true) # Read file and remove newline characters
296
+ lines.reject! do |line| # Remove empty lines and comments
297
+ line.strip.empty? || line.strip.start_with?("#")
298
+ end
299
+ lines.map! do |line| # Strip trailing comments + preceding spaces
300
+ line.sub(/# .*$/, "").strip
301
+ end
302
+ lines # Return cleaned lines
303
+ end
304
+
305
+ def cf_time(t1, t2)
306
+ t1 = t1.split(/- :/, 6)
307
+ t2 = t2.split(/- :/, 6)
308
+ t1 = Time.new(*t1)
309
+ t2 = Time.new(*t2)
310
+ t1 <=> t2
311
+ end
312
+
313
+ def get_asset_path(name)
314
+ if Scriptorium::Repo.testing
315
+ # Development/testing: Check dev_assets first, then local assets
316
+ if File.exist?("dev_assets/#{name}")
317
+ return "dev_assets/#{name}"
318
+ elsif File.exist?("assets/#{name}")
319
+ return "assets/#{name}"
320
+ else
321
+ raise AssetNotFound(name)
322
+ end
323
+ else # Production
324
+ # Production: Check user assets first, then gem assets
325
+
326
+ # Check user assets first (highest priority)
327
+ if File.exist?("assets/#{name}")
328
+ return "assets/#{name}"
329
+ end
330
+
331
+ # Then check gem assets (fallback)
332
+ begin
333
+ gem_spec = Gem.loaded_specs['scriptorium']
334
+ if gem_spec
335
+ gem_asset_path = "#{gem_spec.full_gem_path}/assets/#{name}"
336
+ if File.exist?(gem_asset_path)
337
+ return gem_asset_path
338
+ end
339
+ end
340
+ rescue => e
341
+ # If gem lookup fails, continue without gem assets
342
+ end
343
+
344
+ # Asset not found
345
+ raise AssetNotFound(name)
346
+ end
347
+ end
348
+
349
+ def generate_missing_asset_svg(filename, width: 200, height: 150)
350
+ # Truncate filename if too long for display
351
+ display_name = filename.length > 20 ? filename[0..16] + "..." : filename
352
+
353
+ # Generate SVG with broken image icon and filename
354
+ svg = <<~SVG
355
+ <svg width="#{width}" height="#{height}" xmlns="http://www.w3.org/2000/svg">
356
+ <!-- Background -->
357
+ <rect fill="#f8f9fa" stroke="#ddd" stroke-width="1" width="#{width}" height="#{height}" rx="4"/>
358
+
359
+ <!-- Broken image icon -->
360
+ <g transform="translate(#{width/2}, #{height/2 - 20})">
361
+ <!-- Image frame -->
362
+ <rect x="-15" y="-10" width="30" height="20" fill="none" stroke="#999" stroke-width="1"/>
363
+ <!-- Broken corner -->
364
+ <path d="M 15 -10 L 25 -20 M 15 -10 L 25 0" stroke="#999" stroke-width="1" fill="none"/>
365
+ <!-- Image icon -->
366
+ <rect x="-12" y="-7" width="24" height="14" fill="#e9ecef"/>
367
+ <circle cx="-5" cy="-2" r="2" fill="#999"/>
368
+ <polygon points="-8,8 -2,2 2,6 8,0" fill="#999"/>
369
+ </g>
370
+
371
+ <!-- Filename -->
372
+ <text x="#{width/2}" y="#{height/2 + 15}" text-anchor="middle" fill="#666" font-family="Arial, sans-serif" font-size="11">
373
+ #{escape_html(display_name)}
374
+ </text>
375
+
376
+ <!-- "Asset not found" message -->
377
+ <text x="#{width/2}" y="#{height/2 + 30}" text-anchor="middle" fill="#999" font-family="Arial, sans-serif" font-size="9">
378
+ Asset not found
379
+ </text>
380
+ </svg>
381
+ SVG
382
+
383
+ svg.strip
384
+ end
385
+
386
+ def list_gem_assets
387
+ assets = []
388
+ begin
389
+ gem_spec = Gem.loaded_specs['scriptorium']
390
+ if gem_spec
391
+ gem_assets_dir = "#{gem_spec.full_gem_path}/assets"
392
+ if Dir.exist?(gem_assets_dir)
393
+ Dir.glob("#{gem_assets_dir}/**/*").each do |file|
394
+ next unless File.file?(file)
395
+ relative_path = file.sub("#{gem_assets_dir}/", "")
396
+ assets << relative_path
397
+ end
398
+ end
399
+ end
400
+ rescue => e
401
+ # If gem lookup fails, return empty array
402
+ end
403
+ assets.sort
404
+ end
405
+
406
+ def copy_gem_asset_to_user(asset_name, target_dir = "assets")
407
+ begin
408
+ gem_spec = Gem.loaded_specs['scriptorium']
409
+ if gem_spec
410
+ gem_asset_path = "#{gem_spec.full_gem_path}/assets/#{asset_name}"
411
+ if File.exist?(gem_asset_path)
412
+ # Create target directory if it doesn't exist
413
+ FileUtils.mkdir_p(target_dir) unless Dir.exist?(target_dir)
414
+
415
+ # Copy the asset
416
+ target_path = "#{target_dir}/#{File.basename(asset_name)}"
417
+ FileUtils.cp(gem_asset_path, target_path)
418
+ return target_path
419
+ end
420
+ end
421
+ rescue => e
422
+ # If gem lookup fails, return nil
423
+ end
424
+ nil
425
+ end
426
+
427
+ # Clipboard helper methods
428
+ def copy_to_clipboard(text)
429
+ begin
430
+ require 'clipboard'
431
+ Clipboard.copy(text)
432
+ true
433
+ rescue LoadError => e
434
+ # Fallback to system commands if clipboard gem not available
435
+ case RbConfig::CONFIG['host_os']
436
+ when /darwin/ # macOS
437
+ system("echo '#{text}' | pbcopy")
438
+ when /linux/ # Linux
439
+ system("echo '#{text}' | xclip -selection clipboard")
440
+ when /mswin|mingw|cygwin/ # Windows
441
+ system("echo '#{text}' | clip")
442
+ else
443
+ puts "Clipboard not supported on this OS"
444
+ false
445
+ end
446
+ rescue => e
447
+ puts "Failed to copy to clipboard: #{e.message}"
448
+ false
449
+ end
450
+ end
451
+
452
+ def get_from_clipboard
453
+ begin
454
+ require 'clipboard'
455
+ Clipboard.paste
456
+ rescue LoadError => e
457
+ # Fallback to system commands if clipboard gem not available
458
+ case RbConfig::CONFIG['host_os']
459
+ when /darwin/ # macOS
460
+ `pbpaste`
461
+ when /linux/ # Linux
462
+ `xclip -selection clipboard -o`
463
+ when /mswin|mingw|cygwin/ # Windows
464
+ `powershell -command "Get-Clipboard"`
465
+ else
466
+ puts "Clipboard not supported on this OS"
467
+ nil
468
+ end
469
+ rescue => e
470
+ puts "Failed to read from clipboard: #{e.message}"
471
+ nil
472
+ end
473
+ end
474
+ end
475
+