scriptorium 0.0.3 → 0.7.2

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 (353) 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/icons/social/reddit.png +0 -0
  7. data/assets/icons/social/x-logo.png +0 -0
  8. data/assets/icons/ui/.DS_Store +0 -0
  9. data/assets/icons/ui/back.png +0 -0
  10. data/assets/icons/ui/copy.png +0 -0
  11. data/assets/icons/ui/down.png +0 -0
  12. data/assets/icons/ui/end.png +0 -0
  13. data/assets/icons/ui/exit.png +0 -0
  14. data/assets/icons/ui/foo +10 -0
  15. data/assets/icons/ui/home.png +0 -0
  16. data/assets/icons/ui/left.png +0 -0
  17. data/assets/icons/ui/next.png +0 -0
  18. data/assets/icons/ui/right.png +0 -0
  19. data/assets/icons/ui/start.png +0 -0
  20. data/assets/icons/ui/up.png +0 -0
  21. data/assets/imagenotfound.jpg +0 -0
  22. data/assets/samples/placeholder.svg +9 -0
  23. data/assets/themes/standard/favicon.svg +6 -0
  24. data/bin/sblog +84 -5
  25. data/bin/scriptorium +1 -0
  26. data/doc/README.txt +6 -0
  27. data/doc/anti-amnesia/20250727-054000-scriptorium-overview.md +94 -0
  28. data/doc/anti-amnesia/20250727-123000-anti-amnesia-conventions.md +2 -0
  29. data/doc/anti-amnesia/20250727-172600-cursor-rbenv-ruby-version-mystery.md +45 -0
  30. data/doc/anti-amnesia/20250727-172900-ai-cognitive-assessment-capabilities.md +40 -0
  31. data/doc/anti-amnesia/20250728-124243-aaa-syntax-clarification.md +46 -0
  32. data/doc/anti-amnesia/20250729-210000-reddit-autopost-integration-complete.md +158 -0
  33. data/doc/anti-amnesia/20250804-190500-cognitive-loop-bug.md +35 -0
  34. data/doc/anti-amnesia/20250804-190700-anti-amnesia-timestamping-fix.md +27 -0
  35. data/doc/anti-amnesia/20250807-213025.md +116 -0
  36. data/doc/anti-amnesia/20250901-211714-codemirror-integration-and-web-tests.md +172 -0
  37. data/doc/anti-amnesia/20250902-002402-backup-restore-system.md +126 -0
  38. data/doc/anti-amnesia/20250907-203339-backup-metadata-implementation.md +66 -0
  39. data/doc/banner_svg_config.md +114 -0
  40. data/doc/contrib.lt3 +8 -0
  41. data/doc/dependencies.md +281 -0
  42. data/doc/hacker.lt3 +5 -0
  43. data/doc/imported/0001-elixir-conf-2014/metadata.txt +7 -0
  44. data/doc/imported/0001-elixir-conf-2014/post.html +37 -0
  45. data/doc/imported/0001-elixir-conf-2014/source.lt3 +22 -0
  46. data/doc/imported/0002-programmers-and-word-processing/metadata.txt +7 -0
  47. data/doc/imported/0002-programmers-and-word-processing/post.html +192 -0
  48. data/doc/imported/0002-programmers-and-word-processing/source.lt3 +146 -0
  49. data/doc/imported/0003-how-to-turn-your-brain-sideways/metadata.txt +7 -0
  50. data/doc/imported/0003-how-to-turn-your-brain-sideways/post.html +60 -0
  51. data/doc/imported/0003-how-to-turn-your-brain-sideways/source.lt3 +40 -0
  52. data/doc/imported/0004-upcoming-lone-star-ruby-conference/metadata.txt +7 -0
  53. data/doc/imported/0004-upcoming-lone-star-ruby-conference/post.html +42 -0
  54. data/doc/imported/0004-upcoming-lone-star-ruby-conference/source.lt3 +24 -0
  55. data/doc/imported/0005-elixir-conf-2015-announced/metadata.txt +7 -0
  56. data/doc/imported/0005-elixir-conf-2015-announced/post.html +30 -0
  57. data/doc/imported/0005-elixir-conf-2015-announced/source.lt3 +16 -0
  58. data/doc/imported/0006-ruby-for-dinosaurs/metadata.txt +7 -0
  59. data/doc/imported/0006-ruby-for-dinosaurs/post.html +43 -0
  60. data/doc/imported/0006-ruby-for-dinosaurs/source.lt3 +27 -0
  61. data/doc/imported/0007-phoenix-isnt-rails/metadata.txt +7 -0
  62. data/doc/imported/0007-phoenix-isnt-rails/post.html +116 -0
  63. data/doc/imported/0007-phoenix-isnt-rails/source.lt3 +87 -0
  64. data/doc/imported/0008-concerning-the-term-monkeypatching/metadata.txt +7 -0
  65. data/doc/imported/0008-concerning-the-term-monkeypatching/post.html +129 -0
  66. data/doc/imported/0008-concerning-the-term-monkeypatching/source.lt3 +92 -0
  67. data/doc/imported/0009-announcement-coming-soon/metadata.txt +7 -0
  68. data/doc/imported/0009-announcement-coming-soon/post.html +33 -0
  69. data/doc/imported/0009-announcement-coming-soon/source.lt3 +19 -0
  70. data/doc/imported/0010-immutable-data-ditching-the-wax-tablet/metadata.txt +7 -0
  71. data/doc/imported/0010-immutable-data-ditching-the-wax-tablet/post.html +175 -0
  72. data/doc/imported/0010-immutable-data-ditching-the-wax-tablet/source.lt3 +139 -0
  73. data/doc/imported/0011-computer-science-as-a-lost-art/metadata.txt +7 -0
  74. data/doc/imported/0011-computer-science-as-a-lost-art/post.html +139 -0
  75. data/doc/imported/0011-computer-science-as-a-lost-art/source.lt3 +104 -0
  76. data/doc/imported/0012-ruby-day-in-turin-italy/metadata.txt +7 -0
  77. data/doc/imported/0012-ruby-day-in-turin-italy/post.html +42 -0
  78. data/doc/imported/0012-ruby-day-in-turin-italy/source.lt3 +24 -0
  79. data/doc/imported/0013-rubyday-was-a-success/metadata.txt +7 -0
  80. data/doc/imported/0013-rubyday-was-a-success/post.html +44 -0
  81. data/doc/imported/0013-rubyday-was-a-success/source.lt3 +27 -0
  82. data/doc/imported/0014-working-on-the-blogging-software/metadata.txt +7 -0
  83. data/doc/imported/0014-working-on-the-blogging-software/post.html +63 -0
  84. data/doc/imported/0014-working-on-the-blogging-software/source.lt3 +41 -0
  85. data/doc/imported/0015-ok-its-not-really-a-lost-art/metadata.txt +7 -0
  86. data/doc/imported/0015-ok-its-not-really-a-lost-art/post.html +172 -0
  87. data/doc/imported/0015-ok-its-not-really-a-lost-art/source.lt3 +134 -0
  88. data/doc/imported/0016-an-in-operator-for-ruby/metadata.txt +7 -0
  89. data/doc/imported/0016-an-in-operator-for-ruby/post.html +155 -0
  90. data/doc/imported/0016-an-in-operator-for-ruby/source.lt3 +106 -0
  91. data/doc/imported/0017-the-forgotten-mathematician/metadata.txt +7 -0
  92. data/doc/imported/0017-the-forgotten-mathematician/post.html +161 -0
  93. data/doc/imported/0017-the-forgotten-mathematician/source.lt3 +119 -0
  94. data/doc/imported/0018-ruby-puns/metadata.txt +7 -0
  95. data/doc/imported/0018-ruby-puns/post.html +46 -0
  96. data/doc/imported/0018-ruby-puns/source.lt3 +28 -0
  97. data/doc/imported/0019-custom-exceptions-via-metaprogramming/metadata.txt +7 -0
  98. data/doc/imported/0019-custom-exceptions-via-metaprogramming/post.html +138 -0
  99. data/doc/imported/0019-custom-exceptions-via-metaprogramming/source.lt3 +101 -0
  100. data/doc/imported/0020-fffff/metadata.txt +7 -0
  101. data/doc/imported/0020-fffff/post.html +24 -0
  102. data/doc/imported/0020-fffff/source.lt3 +12 -0
  103. data/doc/imported/0021-trying-ror-yet-again/metadata.txt +7 -0
  104. data/doc/imported/0021-trying-ror-yet-again/post.html +26 -0
  105. data/doc/imported/0021-trying-ror-yet-again/source.lt3 +12 -0
  106. data/doc/imported/0023-doctor-sleep/metadata.txt +7 -0
  107. data/doc/imported/0023-doctor-sleep/post.html +63 -0
  108. data/doc/imported/0023-doctor-sleep/source.lt3 +44 -0
  109. data/doc/imported/0024-just-a-test/metadata.txt +7 -0
  110. data/doc/imported/0024-just-a-test/post.html +24 -0
  111. data/doc/imported/0024-just-a-test/source.lt3 +12 -0
  112. data/doc/imported/import_summary.txt +98 -0
  113. data/doc/livetext-informal-spec.txt +65 -0
  114. data/doc/myuserdoc/ch-0.lt3 +31 -0
  115. data/doc/myuserdoc/ch-1.lt3 +37 -0
  116. data/doc/myuserdoc/ch-10.lt3 +22 -0
  117. data/doc/myuserdoc/ch-2.lt3 +37 -0
  118. data/doc/myuserdoc/ch-3.lt3 +19 -0
  119. data/doc/myuserdoc/ch-4.lt3 +43 -0
  120. data/doc/myuserdoc/ch-5.lt3 +22 -0
  121. data/doc/myuserdoc/ch-6.lt3 +19 -0
  122. data/doc/myuserdoc/ch-7.lt3 +16 -0
  123. data/doc/myuserdoc/ch-8.lt3 +13 -0
  124. data/doc/myuserdoc/ch-9.lt3 +19 -0
  125. data/doc/myuserdoc/tweak.rb +18 -0
  126. data/doc/myuserdoc/userdoc-toc.txt +88 -0
  127. data/doc/old-posts/0001-elixir-conf-2014.lt3 +24 -0
  128. data/doc/old-posts/0002-programmers-and-word-processing.lt3 +150 -0
  129. data/doc/old-posts/0003-how-to-turn-your-brain-sideways.lt3 +43 -0
  130. data/doc/old-posts/0004-upcoming-lone-star-ruby-conference.lt3 +26 -0
  131. data/doc/old-posts/0005-elixir-conf-2015-announced.lt3 +17 -0
  132. data/doc/old-posts/0006-ruby-for-dinosaurs.lt3 +30 -0
  133. data/doc/old-posts/0007-phoenix-isnt-rails.lt3 +90 -0
  134. data/doc/old-posts/0008-concerning-the-term-monkeypatching.lt3 +105 -0
  135. data/doc/old-posts/0009-announcement-coming-soon.lt3 +20 -0
  136. data/doc/old-posts/0010-immutable-data-ditching-the-wax-tablet.lt3 +142 -0
  137. data/doc/old-posts/0011-computer-science-as-a-lost-art.lt3 +117 -0
  138. data/doc/old-posts/0012-ruby-day-in-turin-italy.lt3 +26 -0
  139. data/doc/old-posts/0013-rubyday-was-a-success.lt3 +28 -0
  140. data/doc/old-posts/0014-working-on-the-blogging-software.lt3 +42 -0
  141. data/doc/old-posts/0015-ok-its-not-really-a-lost-art.lt3 +137 -0
  142. data/doc/old-posts/0016-an-in-operator-for-ruby.lt3 +142 -0
  143. data/doc/old-posts/0017-the-forgotten-mathematician.lt3 +129 -0
  144. data/doc/old-posts/0018-ruby-puns.lt3 +31 -0
  145. data/doc/old-posts/0019-custom-exceptions-via-metaprogramming.lt3 +116 -0
  146. data/doc/old-posts/0021-trying-ror-yet-again.lt3 +35 -0
  147. data/doc/old-posts/0023-doctor-sleep.lt3 +43 -0
  148. data/doc/old-posts/0024-just-a-test.lt3 +12 -0
  149. data/doc/old-posts/0025-trying-another-post.lt3 +12 -0
  150. data/doc/old-repo +1 -0
  151. data/doc/reddit_credentials_template.json +8 -0
  152. data/doc/reddit_integration.md +207 -0
  153. data/doc/user.lt3 +35 -0
  154. data/doc/user_guide_section_1.md +137 -0
  155. data/doc/user_guide_section_10.md +515 -0
  156. data/doc/user_guide_section_11.md +708 -0
  157. data/doc/user_guide_section_2.md +233 -0
  158. data/doc/user_guide_section_3.md +5 -0
  159. data/doc/user_guide_section_4.md +221 -0
  160. data/doc/user_guide_section_5.md +243 -0
  161. data/doc/user_guide_section_6.md +147 -0
  162. data/doc/user_guide_section_7.md +311 -0
  163. data/doc/user_guide_section_8.md +224 -0
  164. data/doc/user_guide_section_9.md +375 -0
  165. data/lib/rouge/lexers/livetext.rb +74 -0
  166. data/lib/scriptorium/api.rb +2373 -0
  167. data/lib/scriptorium/banner_svg.rb +729 -0
  168. data/lib/scriptorium/contract.rb +34 -0
  169. data/lib/scriptorium/exceptions.rb +201 -1
  170. data/lib/scriptorium/helpers.rb +675 -0
  171. data/lib/scriptorium/post.rb +259 -0
  172. data/lib/scriptorium/reddit.rb +83 -0
  173. data/lib/scriptorium/repo.rb +938 -0
  174. data/lib/scriptorium/standard_files.rb +149 -0
  175. data/lib/scriptorium/support/bootstrap/css.txt +5 -0
  176. data/lib/scriptorium/support/bootstrap/js.txt +4 -0
  177. data/lib/scriptorium/support/common_js/clipboard.js +35 -0
  178. data/lib/scriptorium/support/common_js/content-loader.js +187 -0
  179. data/lib/scriptorium/support/common_js/navigation.js +52 -0
  180. data/lib/scriptorium/support/common_js/syntax-highlighting.js +27 -0
  181. data/lib/scriptorium/support/config/reddit.txt +10 -0
  182. data/lib/scriptorium/support/config/reddit_template.txt +17 -0
  183. data/lib/scriptorium/support/config/social.txt +8 -0
  184. data/lib/scriptorium/support/highlight/css.txt +2 -0
  185. data/lib/scriptorium/support/highlight/custom.css +119 -0
  186. data/lib/scriptorium/support/highlight/js.txt +1 -0
  187. data/lib/scriptorium/support/post_index/config.txt +15 -0
  188. data/lib/scriptorium/support/post_index/style.css +55 -0
  189. data/lib/scriptorium/support/templates/index_entry.lt3 +16 -0
  190. data/lib/scriptorium/support/templates/initial_post.lt3 +12 -0
  191. data/lib/scriptorium/support/templates/layout.txt +5 -0
  192. data/lib/scriptorium/support/templates/post.lt3 +104 -0
  193. data/lib/scriptorium/support/theme/footer.lt3 +2 -0
  194. data/lib/scriptorium/support/theme/header.lt3 +4 -0
  195. data/lib/scriptorium/support/theme/left.lt3 +3 -0
  196. data/lib/scriptorium/support/theme/main.lt3 +5 -0
  197. data/lib/scriptorium/support/theme/right.lt3 +3 -0
  198. data/lib/scriptorium/theme.rb +192 -0
  199. data/lib/scriptorium/version.rb +1 -1
  200. data/lib/scriptorium/view.rb +1021 -0
  201. data/lib/scriptorium/widgets/featured_posts.rb +149 -0
  202. data/lib/scriptorium/widgets/links.rb +112 -0
  203. data/lib/scriptorium/widgets/pages.rb +133 -0
  204. data/lib/scriptorium/widgets/widget.rb +133 -0
  205. data/lib/scriptorium.rb +38 -34
  206. data/lib/skeleton.rb +10 -1
  207. data/scriptorium.gemspec +17 -5
  208. data/test/README.md +69 -0
  209. data/test/WEB_INTEGRATION_README.md +196 -0
  210. data/test/all +83 -0
  211. data/test/api_demo.rb +99 -0
  212. data/test/assets/imagenotfound.jpg +0 -0
  213. data/test/assets/images/.DS_Store +0 -0
  214. data/test/assets/images/README.md +27 -0
  215. data/test/assets/images/odd_aspect.png +0 -0
  216. data/test/assets/images/perfect.png +0 -0
  217. data/test/assets/images/small.png +0 -0
  218. data/test/assets/images/tall.png +0 -0
  219. data/test/assets/images/very_tall.png +0 -0
  220. data/test/assets/images/very_wide.png +0 -0
  221. data/test/assets/images/wide.png +0 -0
  222. data/test/assets/testbanner.jpg +0 -0
  223. data/test/banner_svg/simple_helpers.rb +13 -0
  224. data/test/banner_svg/unit.rb +1000 -0
  225. data/test/config/deployment.txt +5 -0
  226. data/test/ed_test.rb +204 -0
  227. data/test/integration/cursor_banner_combinations.rb +193 -0
  228. data/test/integration/cursor_banner_features.rb +374 -0
  229. data/test/integration/integration_test.rb +326 -0
  230. data/test/integration/preview_flow_test.rb +94 -0
  231. data/test/livetext_plugin_test.rb +500 -0
  232. data/test/manual/asset_mgmt.rb +67 -0
  233. data/test/manual/banner-tests/index.html +45 -0
  234. data/test/manual/banner-tests/svg.txt +3 -0
  235. data/test/manual/banner-tests/test01.html +122 -0
  236. data/test/manual/banner-tests/test02.html +122 -0
  237. data/test/manual/banner-tests/test03.html +122 -0
  238. data/test/manual/banner-tests/test04.html +129 -0
  239. data/test/manual/banner-tests/test05.html +129 -0
  240. data/test/manual/banner-tests/test06.html +129 -0
  241. data/test/manual/banner-tests/test07.html +129 -0
  242. data/test/manual/banner-tests/test08.html +123 -0
  243. data/test/manual/banner-tests/test09.html +123 -0
  244. data/test/manual/banner-tests/test10.html +123 -0
  245. data/test/manual/banner-tests/test11.html +123 -0
  246. data/test/manual/banner-tests/test12.html +123 -0
  247. data/test/manual/banner-tests/test13.html +123 -0
  248. data/test/manual/banner-tests/test14.html +123 -0
  249. data/test/manual/banner-tests/test15.html +122 -0
  250. data/test/manual/banner-tests/test16.html +122 -0
  251. data/test/manual/banner-tests/test17.html +122 -0
  252. data/test/manual/banner-tests/test18.html +132 -0
  253. data/test/manual/banner-tests/test19.html +132 -0
  254. data/test/manual/banner-tests/test20.html +132 -0
  255. data/test/manual/banner-tests/test21.html +132 -0
  256. data/test/manual/banner-tests/test22.html +132 -0
  257. data/test/manual/banner-tests/test23.html +132 -0
  258. data/test/manual/banner-tests/test24.html +132 -0
  259. data/test/manual/banner-tests/test25.html +131 -0
  260. data/test/manual/banner_environment.rb +205 -0
  261. data/test/manual/codemirror_demo.html +773 -0
  262. data/test/manual/create_posts_for_web.rb +114 -0
  263. data/test/manual/environment.rb +67 -0
  264. data/test/manual/make_banner.rb +153 -0
  265. data/test/manual/preview_manual_test.rb +129 -0
  266. data/test/manual/sample_banner_config.txt +12 -0
  267. data/test/manual/test_advanced_widgets.rb +73 -0
  268. data/test/manual/test_banner_combinations.rb +120 -0
  269. data/test/manual/test_banner_features.rb +306 -0
  270. data/test/manual/test_banner_integration.rb +115 -0
  271. data/test/manual/test_banner_radial.rb +87 -0
  272. data/test/manual/test_basic_posts.rb +47 -0
  273. data/test/manual/test_layout_widgets.rb +40 -0
  274. data/test/manual/test_pagination.rb +24 -0
  275. data/test/manual/test_random_posts.rb +38 -0
  276. data/test/manual/test_syntax_highlighting.rb +167 -0
  277. data/test/rubytext/rubytext_comprehensive_test.rb +307 -0
  278. data/test/rubytext/rubytext_demo_test.rb +42 -0
  279. data/test/rubytext/rubytext_testing_guide.md +277 -0
  280. data/test/run_automated_tests.rb +45 -0
  281. data/test/staging/.DS_Store +0 -0
  282. data/test/support/preview_utils.rb +88 -0
  283. data/test/syntax_highlighting_test.lt3 +124 -0
  284. data/test/test_gem_assets.rb +48 -0
  285. data/test/test_helpers.rb +240 -0
  286. data/test/tui_editor_integration_test.rb +296 -0
  287. data/test/tui_integration_test.rb +883 -0
  288. data/test/unit/api.rb +1776 -0
  289. data/test/unit/asset_management.rb +219 -0
  290. data/test/unit/backup_test.rb +451 -0
  291. data/test/unit/clipboard_test.rb +60 -0
  292. data/test/unit/contract_test.rb +69 -0
  293. data/test/unit/core.rb +1211 -0
  294. data/test/unit/deploy_config_test.rb +248 -0
  295. data/test/unit/deploy_test.rb +478 -0
  296. data/test/unit/edit_post_test.rb +168 -0
  297. data/test/unit/gem_asset_management.rb +183 -0
  298. data/test/unit/livetext_basic.rb +57 -0
  299. data/test/unit/livetext_compatibility.rb +82 -0
  300. data/test/unit/parse_cmd_test.rb +260 -0
  301. data/test/unit/permalink_copy_test.rb +211 -0
  302. data/test/unit/post.rb +309 -0
  303. data/test/unit/post_index_config_test.rb +258 -0
  304. data/test/unit/post_state_helpers_test.rb +137 -0
  305. data/test/unit/read_commented_file_test.rb +278 -0
  306. data/test/unit/reddit_test.rb +235 -0
  307. data/test/unit/repo.rb +569 -0
  308. data/test/unit/social_test.rb +366 -0
  309. data/test/unit/syntax_highlighting.rb +70 -0
  310. data/test/unit/theme_management_test.rb +91 -0
  311. data/test/unit/view.rb +498 -0
  312. data/test/unit/widgets.rb +669 -0
  313. data/test/web_integration_test.rb +231 -0
  314. data/test/web_test_helper.rb +218 -0
  315. data/test/web_workflow_test.rb +527 -0
  316. data/test/wizard_test.rb +123 -0
  317. data/ui/README.md +67 -0
  318. data/ui/common/lib/ui_common.rb +8 -0
  319. data/ui/rubytext/README.md +191 -0
  320. data/ui/rubytext/bin/scriptorium-rubytext +402 -0
  321. data/ui/rubytext/lib/rubytext_ui.rb +300 -0
  322. data/ui/tui/bin/scriptorium +1890 -0
  323. data/ui/tui/test/tui_test.rb +23 -0
  324. data/ui/web/app/app.rb +2600 -0
  325. data/ui/web/app/assets/livetext_mode.js +244 -0
  326. data/ui/web/app/error_helpers.rb +150 -0
  327. data/ui/web/app/views/advanced_config.erb +196 -0
  328. data/ui/web/app/views/asset_management.erb +645 -0
  329. data/ui/web/app/views/backup_management.erb +238 -0
  330. data/ui/web/app/views/banner_config.erb +200 -0
  331. data/ui/web/app/views/config_widget.erb +232 -0
  332. data/ui/web/app/views/configure_view.erb +401 -0
  333. data/ui/web/app/views/dashboard.erb +154 -0
  334. data/ui/web/app/views/deploy_config.erb +149 -0
  335. data/ui/web/app/views/edit_pages.erb +363 -0
  336. data/ui/web/app/views/edit_post.erb +175 -0
  337. data/ui/web/app/views/edit_theme.erb +73 -0
  338. data/ui/web/app/views/edit_theme_file.erb +74 -0
  339. data/ui/web/app/views/error_page.erb +29 -0
  340. data/ui/web/app/views/header_config.erb +155 -0
  341. data/ui/web/app/views/layout_config.erb +147 -0
  342. data/ui/web/app/views/navbar_config.erb +411 -0
  343. data/ui/web/app/views/theme_management.erb +130 -0
  344. data/ui/web/app/views/view_dashboard.erb +779 -0
  345. data/ui/web/app/views/widgets.erb +249 -0
  346. data/ui/web/bin/scriptorium-web +164 -0
  347. data/ui/web/test/web_basic_test.rb +38 -0
  348. data/ui/web/test_navbar.txt +7 -0
  349. data/ui/web/tmp/timing.log +17 -0
  350. data/ui/web/tmp/web_server.log +0 -0
  351. metadata +434 -8
  352. data/lib/scriptorium/engine.rb +0 -22
  353. data/test/engine/unit.rb +0 -44
@@ -0,0 +1,1021 @@
1
+ require 'fileutils'
2
+
3
+ class Scriptorium::View
4
+ include Scriptorium::Exceptions
5
+ include Scriptorium::Helpers
6
+ include Scriptorium::Contract
7
+
8
+ attr_reader :name, :title, :subtitle, :theme, :dir
9
+
10
+ def self.create_sample_view(repo)
11
+ repo.create_view("sample", "My first view", "This is just a sample")
12
+ # repo.generate_front_page("sample")
13
+ end
14
+
15
+ # Invariants
16
+ def define_invariants
17
+ invariant { @name.is_a?(String) && !@name.empty? }
18
+ invariant { @title.is_a?(String) && !@title.empty? }
19
+ invariant { @subtitle.is_a?(String) }
20
+ invariant { @theme.is_a?(String) && !@theme.empty? }
21
+ invariant { @root.is_a?(String) && !@root.empty? }
22
+ invariant { @repo.is_a?(Scriptorium::Repo) }
23
+ invariant { @dir.is_a?(String) && !@dir.empty? }
24
+ end
25
+
26
+ def initialize(name, title, subtitle = "", theme = "standard")
27
+ msg = "name must be a String, got #{name.class}"
28
+ assume(msg) { name.is_a?(String) }
29
+ msg = "title must be a String, got #{title.class}"
30
+ assume(msg) { title.is_a?(String) }
31
+ msg = "subtitle must be a String, got #{subtitle.class}"
32
+ assume(msg) { subtitle.is_a?(String) }
33
+ msg = "theme must be a String, got #{theme.class}"
34
+ assume(msg) { theme.is_a?(String) }
35
+
36
+ validate_name(name)
37
+ validate_title(title)
38
+
39
+ @name, @title, @subtitle, @theme = name, title, subtitle, theme
40
+ @root = Scriptorium::Repo.root
41
+ @repo = Scriptorium::Repo.repo
42
+ @dir = "#@root/views/#{name}"
43
+ @predef = Scriptorium::StandardFiles.new
44
+
45
+ define_invariants
46
+ verify { @name == name }
47
+ verify { @title == title }
48
+ check_invariants
49
+ end
50
+
51
+ def inspect
52
+ "<View: #@name #{@title.inspect} theme: #@theme>"
53
+ end
54
+
55
+ private def validate_name(name)
56
+ raise ViewNameNil if name.nil?
57
+
58
+ raise ViewNameEmpty if name.to_s.strip.empty?
59
+
60
+ unless name.match?(/^[a-zA-Z0-9_-]+$/)
61
+ raise ViewNameInvalid(name)
62
+ end
63
+ end
64
+
65
+ private def validate_title(title)
66
+ raise ViewTitleNil if title.nil?
67
+
68
+ raise ViewTitleEmpty if title.to_s.strip.empty?
69
+ end
70
+
71
+ def read_layout
72
+ layout_file = @dir/:config/"layout.txt"
73
+
74
+ need(:file, layout_file, LayoutFileMissing)
75
+
76
+ lines = read_commented_file(layout_file)
77
+ containers = {}
78
+ secs = []
79
+ lines.each do |line|
80
+ sec, args = line.split(/\s+/, 2)
81
+ containers[sec] = (args || "")
82
+ secs << sec
83
+ end
84
+ directives = %w[header footer left right main]
85
+ secs.each {|sec| raise LayoutHasUnknownTag(sec) unless directives.include?(sec)}
86
+ directives.each {|sec| raise LayoutHasDuplicateTags(sec) if lines.count(sec) > 1}
87
+ containers
88
+ end
89
+
90
+ def generate_empty_containers
91
+ layout_file = @dir/:config/"layout.txt"
92
+ return unless File.exist?(layout_file)
93
+
94
+ flexing = {
95
+ header: %[id="header" class="header" style="padding: 10px; width: 100%; box-sizing: border-box;"],
96
+ footer: %[class="footer" style="background: lightgray; padding: 10px;"],
97
+ left: %[class="left" style="width: %{width}; background: #f0f0f0; padding: 10px; flex-grow: 0; flex-shrink: 0;"],
98
+ right: %[class="right" style="width: %{width}; background: #f0f0f0; padding: 10px; flex-grow: 0; flex-shrink: 0;"],
99
+ main: %[class="main" style="flex-grow: 1; padding: 10px;"]
100
+ }
101
+ sections = read_layout
102
+ lines = sections.keys
103
+ # FIXME Pleeeease refactor this.
104
+ lines.each do |section|
105
+ args = sections[section] # like 20% for right, left
106
+ filename = @dir/:layout/"#{section}.html"
107
+ tag = section # header, footer, main
108
+ tag = "aside" if section == 'left' || tag == 'right'
109
+
110
+ inline = flexing[section.to_sym]
111
+ if section == "left" || section == "right"
112
+ mod = {width: args}
113
+ inline = inline % mod
114
+ end
115
+ content = <<~HTML
116
+ <#{tag} #{inline}>
117
+ <!-- Section: #{section} -->
118
+ </#{tag}>
119
+ HTML
120
+
121
+ write_file(filename, content)
122
+ end
123
+ end
124
+
125
+ def theme(change = nil)
126
+ return @theme if change.nil?
127
+ # what if it doesn't exist?
128
+ need(:dir, @root/:themes/change, ThemeDoesntExist)
129
+ @theme = change
130
+ change_config(@dir/"config.txt", "theme", change)
131
+ apply_theme(change)
132
+ end
133
+
134
+ def apply_theme(theme)
135
+ check_invariants
136
+ msg = "theme must be a non-empty String, got #{theme.class} (#{theme.inspect})"
137
+ assume(msg) { theme.is_a?(String) && !theme.empty? }
138
+
139
+ # check to see if ever done before?
140
+ # copy layout.txt to view
141
+ t = Scriptorium::Theme.new(@root, theme)
142
+ need(:file, t.file("layout.txt"), ThemeFileNotFound)
143
+ FileUtils.cp(t.file("layout.txt"), @dir/:config)
144
+ # copy other .txt to view? header, footer, ...
145
+ names = %w[header footer left right main]
146
+ lay = @root/:themes/theme/:layout
147
+ names.each do |name|
148
+ f1, f2 = lay/:config/"#{name}.txt", dir/:config
149
+ need(:file, f1, ThemeFileNotFound)
150
+ FileUtils.cp(f1, f2)
151
+ end
152
+ generate_empty_containers
153
+
154
+ verify { @theme == theme }
155
+ check_invariants
156
+ end
157
+
158
+ def content_tag(section)
159
+ "<!-- Section: #{section} -->"
160
+ end
161
+
162
+ def placeholder_text(str)
163
+ if str.start_with?("@")
164
+ file = @dir/:config/:text/"#{str[1..]}"
165
+ read_file(file, missing_fallback: "[Missing: #{file}]")
166
+ else
167
+ str
168
+ end
169
+ end
170
+
171
+ def section_append(sec, str)
172
+ file = @dir/:config/"#{sec}.txt"
173
+ text = read_file(file)
174
+ text << str
175
+ write_file(file, text)
176
+ end
177
+
178
+ def section_hash(section)
179
+ hash = Hash.new { |hash, key| ->(arg = nil) { "<!-- Not defined for key: #{key} -->\n" } }
180
+ hash["text"] = ->(arg) { " <p>" + placeholder_text(arg) + "</p>\n" }
181
+ hash
182
+ end
183
+
184
+ def section_core(section, hash)
185
+ cfg = @dir/:config
186
+ sectxt = cfg/"#{section}.txt"
187
+
188
+ # Only add placeholder if section has no real content
189
+ lines = read_commented_file(sectxt)
190
+ if lines.empty? && section != "main"
191
+ section_append(section, "\ntext This is #{section}...")
192
+ lines = read_commented_file(sectxt)
193
+ end
194
+
195
+ result = "<!-- Section: #{section} (output) -->\n"
196
+ lines.each do |line|
197
+ component, arg = line.split(/\s+/, 2)
198
+
199
+ # Handle malformed config lines
200
+ if component.nil? || component.strip.empty?
201
+ result << "<!-- Invalid config line: #{line.inspect} -->\n"
202
+ next
203
+ end
204
+
205
+ component = component.downcase
206
+ if hash.key?(component)
207
+ result << hash[component].call(arg)
208
+ else
209
+ result << "<!-- Unknown component: #{component} -->\n"
210
+ end
211
+ end
212
+ result
213
+ end
214
+
215
+ =begin
216
+ To build a header, I start with two things:
217
+ config/header.txt (which is user-supplied and has things such as "title" in it); and
218
+ layout/header.html (which is a template with <header> tags enclosing at least a line
219
+ like "<!-- Section: header -->"
220
+
221
+ get core: I process header.txt line by line, gathering the "core" or "guts" of the header.
222
+ sub into template: I substitute this into the template contents and
223
+ write output: write the result to output/panes/header.html
224
+ =end
225
+
226
+ def build_section(section, hash2 = {}, args = "")
227
+ template = @dir/:layout/"#{section}.html"
228
+ output = @dir/:output/:panes/"#{section}.html"
229
+
230
+ # Ensure output directory exists
231
+ FileUtils.mkdir_p(File.dirname(output))
232
+
233
+ # Check if template exists
234
+ need(:file, template)
235
+
236
+ hash = section_hash(section)
237
+ hash.merge!(hash2)
238
+ core = section_core(section, hash)
239
+
240
+ temp_txt = read_file(template)
241
+
242
+ target = content_tag(section)
243
+ temp_txt.sub!(target, core)
244
+
245
+ write_file(output, temp_txt)
246
+
247
+ html = read_file(output)
248
+ html
249
+ end
250
+
251
+ def build_header(sections)
252
+ args = sections["header"]
253
+ return "" unless args
254
+ h2 = {
255
+ "title" => ->(arg = nil) { " <h1>#{escape_html(@title)}</h1>" },
256
+ "subtitle" => ->(arg = nil) { " <p>#{escape_html(@subtitle)}</p>" },
257
+ "nav" => ->(arg = nil) { build_nav(arg) },
258
+ "banner" => ->(arg = nil) { build_banner(arg) }
259
+ }
260
+
261
+ build_section("header", h2, args)
262
+ end
263
+
264
+ ### Helpers for header
265
+
266
+ def build_banner(arg)
267
+ # Check if this is an SVG banner request
268
+ return build_banner_svg("svg") if arg == "svg"
269
+
270
+ # Otherwise, treat as image filename
271
+ return build_banner_image(arg)
272
+ end
273
+
274
+
275
+
276
+ def build_banner_image(image_filename)
277
+ # Search for image in multiple locations
278
+ image_paths = [
279
+ @dir/:assets/image_filename, # view/assets/
280
+ @repo.root/:assets/image_filename, # repo/assets/
281
+ ]
282
+
283
+ # Find the first existing image
284
+ image_path = image_paths.find { |path| File.exist?(path) }
285
+
286
+ if image_path
287
+ # Use relative path for the img src
288
+ if image_path.to_s.start_with?(@dir.to_s)
289
+ # Image is in view directory, use relative path
290
+ relative_path = image_path.to_s.sub(@dir.to_s + "/", "")
291
+ else
292
+ # Image is in repo directory, use relative path from view
293
+ relative_path = "../assets/#{image_filename}"
294
+ end
295
+ html = %[<img src='#{relative_path}' alt='Banner Image' style='width: 100%; height: auto;' />]
296
+ return html
297
+ else
298
+ # Try to copy from global assets
299
+ global_assets_dir = @repo.root/:assets
300
+ global_image_path = global_assets_dir/image_filename
301
+
302
+ if File.exist?(global_image_path)
303
+ # Copy to view assets
304
+ view_assets_dir = @dir/:assets
305
+ make_dir(view_assets_dir) unless Dir.exist?(view_assets_dir)
306
+ FileUtils.cp(global_image_path, view_assets_dir/image_filename)
307
+
308
+ # Use relative path
309
+ relative_path = "assets/#{image_filename}"
310
+ html = %[<img src='#{relative_path}' alt='Banner Image' style='width: 100%; height: auto;' />]
311
+ return html
312
+ else
313
+ # Image not found anywhere
314
+ html = %[<p>Banner image missing: #{image_filename}</p>]
315
+ return html
316
+ end
317
+ end
318
+ end
319
+
320
+ def build_banner_svg(arg)
321
+ bsvg = Scriptorium::BannerSVG.new(@title, @subtitle)
322
+
323
+ # Look for svg.txt file in the view's config directory
324
+ svg_config_file = @dir/:config/"svg.txt"
325
+ if File.exist?(svg_config_file)
326
+ bsvg.parse_header_svg(svg_config_file)
327
+ else
328
+ # No svg.txt file, just use defaults
329
+ bsvg.parse_header_svg
330
+ end
331
+
332
+ bsvg.get_svg
333
+ end
334
+
335
+ def build_nav(arg)
336
+ # Determine navbar file - if no arg, use navbar.txt, otherwise use specified file
337
+ nav_file = if arg.nil? || arg.strip.empty?
338
+ @dir/:config/"navbar.txt"
339
+ else
340
+ @dir/:config/"#{arg}"
341
+ end
342
+
343
+ # Read navbar content with fallback for missing files
344
+ nav_content = read_file(nav_file, missing_fallback: "<p>Navigation not available</p>")
345
+
346
+ # Parse and generate Bootstrap navbar
347
+ generate_bootstrap_navbar(nav_content)
348
+ end
349
+
350
+ def generate_bootstrap_navbar(nav_content)
351
+ menu_items = parse_navbar_content(nav_content)
352
+
353
+ # Generate Bootstrap navbar HTML wrapped in bootstrap-scope
354
+ html = <<~HTML
355
+ <div class="bootstrap-scope">
356
+ <nav class="navbar navbar-expand-lg navbar-light bg-light">
357
+ <div class="container-fluid">
358
+ <button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav" aria-controls="navbarNav" aria-expanded="false" aria-label="Toggle navigation">
359
+ <span class="navbar-toggler-icon"></span>
360
+ </button>
361
+ <div class="collapse navbar-collapse" id="navbarNav">
362
+ <ul class="navbar-nav">
363
+ #{generate_navbar_items(menu_items)}
364
+ </ul>
365
+ </div>
366
+ </div>
367
+ </nav>
368
+ </div>
369
+ HTML
370
+
371
+ html
372
+ end
373
+
374
+ def parse_navbar_content(content)
375
+ menu_items = []
376
+ current_dropdown = nil
377
+
378
+ content.lines.each do |line|
379
+ line = line.rstrip # Keep leading spaces, remove trailing
380
+ next if line.empty? || line.start_with?('#')
381
+
382
+ if line.start_with?('=')
383
+ # Top-level dropdown item
384
+ label = line[1..-1].strip
385
+ current_dropdown = { type: :dropdown, label: label, children: [] }
386
+ menu_items << current_dropdown
387
+ elsif line.start_with?(' ')
388
+ # Child of previous dropdown
389
+ if current_dropdown
390
+ # Remove leading spaces and split on multiple spaces
391
+ clean_line = line.strip
392
+ if clean_line.include?(' ') # Look for multiple spaces
393
+ parts = clean_line.split(/\s{2,}/, 2) # Split on 2+ spaces
394
+ if parts.length >= 2
395
+ title, filename = parts[0], parts[1]
396
+ current_dropdown[:children] << { type: :child, title: title, filename: filename }
397
+ end
398
+ end
399
+ end
400
+ elsif line.start_with?('-')
401
+ # Top-level item (no children)
402
+ clean_line = line[1..-1].strip
403
+ if clean_line.include?(' ') # Look for multiple spaces
404
+ parts = clean_line.split(/\s{2,}/, 2) # Split on 2+ spaces
405
+ if parts.length >= 2
406
+ title, filename = parts[0], parts[1]
407
+ menu_items << { type: :item, title: title, filename: filename }
408
+ end
409
+ end
410
+ end
411
+ end
412
+
413
+ menu_items
414
+ end
415
+
416
+ def generate_navbar_items(menu_items)
417
+ html = ""
418
+
419
+ menu_items.each do |item|
420
+ case item[:type]
421
+ when :dropdown
422
+ html << generate_dropdown_item(item)
423
+ when :item
424
+ html << generate_nav_item(item)
425
+ end
426
+ end
427
+
428
+ html
429
+ end
430
+
431
+ def generate_dropdown_item(item)
432
+ html = <<~HTML
433
+ <li class="nav-item dropdown">
434
+ <a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown" aria-expanded="false">
435
+ #{escape_html(item[:label])}
436
+ </a>
437
+ <ul class="dropdown-menu">
438
+ HTML
439
+
440
+ item[:children].each {|child| html << generate_dropdown_child(child) }
441
+
442
+ html << <<~HTML
443
+ </ul>
444
+ </li>
445
+ HTML
446
+
447
+ html
448
+ end
449
+
450
+ def generate_dropdown_child(child)
451
+ link_url, warning = get_page_link(child[:filename])
452
+
453
+ html = <<~HTML
454
+ <li><a class="dropdown-item" href="javascript:void(0)" onclick="load_main('#{link_url}')">#{escape_html(child[:title])}</a></li>
455
+ HTML
456
+
457
+ html << "<!-- #{warning} -->\n" if warning
458
+
459
+ html
460
+ end
461
+
462
+ def generate_nav_item(item)
463
+ link_url, warning = get_page_link(item[:filename])
464
+
465
+ html = <<~HTML
466
+ <li class="nav-item">
467
+ <a class="nav-link" href="javascript:void(0)" onclick="load_main('#{link_url}')">#{escape_html(item[:title])}</a>
468
+ </li>
469
+ HTML
470
+
471
+ html << "<!-- #{warning} -->\n" if warning
472
+
473
+ html
474
+ end
475
+
476
+ def get_page_link(filename)
477
+ # Check if the page file exists
478
+ page_file = @dir/:pages/"#{filename}.html"
479
+
480
+ if File.exist?(page_file)
481
+ # Page exists, return relative path
482
+ link_url = "pages/#{filename}.html"
483
+ warning = nil
484
+ else
485
+ # Page doesn't exist, still create link but warn
486
+ link_url = "pages/#{filename}.html"
487
+ warning = "Warning: Page file '#{filename}.html' not found in pages directory"
488
+ end
489
+
490
+ [link_url, warning]
491
+ end
492
+
493
+ def build_widgets(arg)
494
+ check_invariants
495
+ msg = "arg must be a String, got #{arg.class}"
496
+ assume(msg) { arg.is_a?(String) }
497
+ validate_widget_arg(arg)
498
+
499
+ widgets = arg.split
500
+ content = ""
501
+ widgets.each do |widget|
502
+ validate_widget_name(widget)
503
+
504
+ begin
505
+ widget_class = eval("Scriptorium::Widget::#{widget.capitalize}")
506
+ obj = widget_class.new(@repo, self)
507
+ obj.generate
508
+ content << obj.card
509
+ rescue => e
510
+ raise CannotBuildWidget("Failed to build widget '#{widget}': #{e.message}")
511
+ end
512
+ end
513
+
514
+ # Wrap widgets in bootstrap-scope container
515
+ if content.strip.empty?
516
+ result = content
517
+ else
518
+ result = <<~HTML
519
+ <div class="bootstrap-scope">
520
+ #{content}
521
+ </div>
522
+ HTML
523
+ end
524
+
525
+ verify { result.is_a?(String) }
526
+ check_invariants
527
+ result
528
+ end
529
+
530
+ private def validate_widget_arg(arg)
531
+ raise WidgetsArgNil if arg.nil?
532
+
533
+ raise WidgetsArgEmpty if arg.to_s.strip.empty?
534
+ end
535
+
536
+ private def validate_widget_name(name)
537
+ raise WidgetNameNil if name.nil? || name.strip.empty?
538
+
539
+ unless name.match?(/^[a-zA-Z0-9_]+$/)
540
+ raise WidgetNameInvalid(name)
541
+ end
542
+ end
543
+
544
+ ###
545
+
546
+ def build_footer(sections)
547
+ args = sections["footer"]
548
+ return "" unless args
549
+ build_section("footer", {}, args)
550
+ end
551
+
552
+ def build_left(sections)
553
+ args = sections["left"]
554
+ return "" unless args
555
+ h2 = { "widget" => ->(arg = nil) { build_widgets(arg) } }
556
+ build_section("left", h2, args)
557
+ end
558
+
559
+ def build_right(sections)
560
+ args = sections["right"]
561
+ return "" unless args
562
+ h2 = { "widget" => ->(arg = nil) { build_widgets(arg) } }
563
+ build_section("right", h2, args)
564
+ end
565
+
566
+ def build_main(sections)
567
+ args = sections["main"]
568
+ return "" unless args
569
+ html = " <!-- Section: main (output) -->\n"
570
+ html << %[ <div id="main" class="main" style="flex-grow: 1; padding: 5px; overflow-y: auto; position: relative; display: block;">]
571
+ # html << %[<div id="main" class="main" style="position: relative; display: flex; flex-direction: column;">\n]
572
+ html << support_data('post_index/style.css')
573
+ if view_posts.empty?
574
+ html << " <h1>No posts yet!</h1>"
575
+ else
576
+ paginate_posts
577
+ need(:file, self.dir/:output/"post_index.html")
578
+ html << read_file(self.dir/:output/"post_index.html")
579
+ end
580
+ html << "</div> <!-- end main -->\n"
581
+ end
582
+
583
+ def generate_post_index
584
+ posts = @repo.all_posts(self)
585
+ config = read_post_index_config
586
+
587
+ # Build table with configurable margin-top
588
+ margin_top = config[:"index.margin.top"] || "0px"
589
+ str = "<table width=100% cellpadding=#{config[:'entry.cellpadding']} style=\"margin-top: #{margin_top};\">"
590
+
591
+ # FIXME - many decisions to make here...
592
+ posts.each {|post| str << post_index_entry(post) }
593
+ str << "</table>"
594
+
595
+ write_file(@dir/:output/"post_index.html", str)
596
+ end
597
+
598
+ def post_index_entry(post)
599
+ template = support_data('templates/index_entry.lt3')
600
+ config = read_post_index_config
601
+ vars = post.vars.merge(config)
602
+
603
+ # Add formatted_date to vars (use post.date to get pubdate/created fallback)
604
+ date_format = config[:"entry.date.format"] || "month dd break yyyy"
605
+ vars[:formatted_date] = format_date(date_format, post.date)
606
+
607
+ entry = substitute(vars, template)
608
+ entry
609
+ end
610
+
611
+ def read_post_index_config
612
+ # Read global defaults first
613
+ global_config_file = @repo.root/:config/"post_index_defaults.txt"
614
+ global_config = File.exist?(global_config_file) ? parse_commented_file(global_config_file) : {}
615
+
616
+ # Read view-specific overrides
617
+ view_config_file = @dir/:config/"post_index.txt"
618
+ view_config = File.exist?(view_config_file) ? parse_commented_file(view_config_file) : {}
619
+
620
+ # Merge: view config overrides global defaults
621
+ global_config.merge(view_config)
622
+ rescue => e
623
+ {}
624
+ end
625
+
626
+ def post_index_array
627
+ posts = view_posts.sort {|a,b| post_compare(a, b) }
628
+ posts.map {|post| post_index_entry(post)}
629
+ end
630
+
631
+ def view_posts
632
+ @repo.all_posts(self).sort {|a,b| post_compare(a, b)}
633
+ end
634
+
635
+ def generate_html_head(view = nil)
636
+ # FIXME - view does not yet override global
637
+ global_head = @root/:config/"global-head.txt"
638
+ view_head = @dir/:config/"global-head.txt"
639
+ head_file = view ? view_head : global_head
640
+ which = view ? "view" : "global"
641
+ line1 = "<!-- head info from #{which} -->"
642
+ lines = read_commented_file(head_file)
643
+ content = "<head>\n#{line1}\n<title>#{@title}</title>\n"
644
+ lines.each do |line|
645
+ component, args = line.split(/\s+/, 2)
646
+ case component.downcase
647
+ when "charset"
648
+ @charset = args
649
+ content << %[<meta charset="#{args}">\n]
650
+ when "desc"
651
+ @desc = args
652
+ content << %[<meta name="description" content="#{args}">\n]
653
+ when "viewport"
654
+ @viewport = args
655
+ str = args.split.join(" ")
656
+ content << %[<meta name="viewport" content="#{str}">\n]
657
+ when "robots"
658
+ @robots = args
659
+ str = args.split.join(", ")
660
+ content << %[<meta name="robots" content="#{str}">\n]
661
+ # when "javascript"
662
+ # content << get_common_js(view)
663
+ when "bootstrap"
664
+ content << generate_bootstrap_css(view)
665
+ when "social"
666
+ content << generate_social_meta_tags(args)
667
+ when "highlight"
668
+ content << generate_highlight_css(view) # loads highlight_css.txt
669
+ when "highlight_custom"
670
+ content << support_data('highlight/custom.css')
671
+ end
672
+ end
673
+ content << "</head>\n"
674
+ content
675
+ end
676
+
677
+ def get_common_js(view = nil)
678
+ global_js = @root/:config/"common.js"
679
+ view_js = @dir/:config/"common.js"
680
+ js_file = view ? view_js : global_js
681
+ code = read_file(js_file)
682
+ return %[<script>#{code}</script>\n]
683
+ end
684
+
685
+ def generate_bootstrap_css(view = nil)
686
+ global_boot = @root/:config/"bootstrap_css.txt"
687
+ view_boot = @dir/:config/"bootstrap_css.txt"
688
+ bs_file = view ? view_boot : global_boot
689
+ lines = read_commented_file(bs_file)
690
+ href = rel = integrity = crossorigin = nil
691
+ lines.each do |line|
692
+ component, args = line.split(/\s+/, 2)
693
+ case component.downcase
694
+ when "href"
695
+ href = args
696
+ when "rel"
697
+ rel = args
698
+ when "integrity"
699
+ integrity = args
700
+ when "crossorigin"
701
+ crossorigin = args
702
+ end
703
+ end
704
+ # content = %[<link rel="#{rel}" href="#{href}" integrity="#{integrity}" crossorigin="#{crossorigin}">\n]
705
+ content = %[<link rel="stylesheet" href="#{href}"></link>\n]
706
+ content
707
+ end
708
+
709
+
710
+
711
+ def generate_bootstrap_js(view = nil)
712
+ global_boot = @root/:config/"bootstrap_js.txt"
713
+ view_boot = @dir/:config/"bootstrap_js.txt"
714
+ bs_file = view ? view_boot : global_boot
715
+ lines = read_commented_file(bs_file)
716
+ src = integrity = crossorigin = nil
717
+ lines.each do |line|
718
+ component, args = line.split(/\s+/, 2)
719
+ case component.downcase
720
+ when "src"
721
+ src = args
722
+ when "rel"
723
+ # rel = args
724
+ when "integrity"
725
+ integrity = args
726
+ when "crossorigin"
727
+ crossorigin = args
728
+ end
729
+ end
730
+ # content = %[<script src="#{src}" integrity="#{integrity}" crossorigin="#{crossorigin}"></script>\n]
731
+ content = %[<script src="#{src}"></script>\n]
732
+ content
733
+ end
734
+
735
+ def generate_social_meta_tags(args = nil, post_data = nil)
736
+ # Check if social is enabled for this view
737
+ social_config_file = @dir/:config/"social.txt"
738
+ return "" unless File.exist?(social_config_file)
739
+
740
+ # Read social configuration
741
+ social_config = read_commented_file(social_config_file)
742
+ platforms = []
743
+
744
+ # Each non-comment line is a platform name
745
+ social_config.each do |line|
746
+ platform = line.strip.downcase
747
+ platforms << platform if platform.match?(/^(facebook|twitter|linkedin|reddit)$/)
748
+ end
749
+
750
+ return "" if platforms.empty?
751
+
752
+ # Determine if this is for a specific post or the main page
753
+ is_post = !post_data.nil?
754
+
755
+ # Get the appropriate title, description, and URL
756
+ if is_post
757
+ title = post_data[:"post.title"] || @title
758
+ description = post_data[:"post.blurb"] || (post_data[:"post.body"] ? post_data[:"post.body"][0..200] : nil) || @desc || @subtitle || @title
759
+ slug = post_data[:"post.slug"] || (post_data[:"post.id"] ? slugify(post_data[:"post.id"], title) : 'post')
760
+ url = "posts/#{slug}#{slug.end_with?('.html') ? '' : '.html'}"
761
+ type = "article"
762
+ else
763
+ title = @title
764
+ description = @desc || @subtitle || @title
765
+ url = "index.html"
766
+ type = "website"
767
+ end
768
+
769
+ # Generate meta tags
770
+ content = ""
771
+
772
+ # Open Graph meta tags (Facebook, LinkedIn, etc.)
773
+ if platforms.include?("facebook") || platforms.include?("linkedin")
774
+ content << %[<meta property="og:title" content="#{escape_html(title)}">\n]
775
+ content << %[<meta property="og:type" content="#{type}">\n]
776
+ content << %[<meta property="og:url" content="#{url}">\n]
777
+ content << %[<meta property="og:description" content="#{escape_html(description)}">\n]
778
+ content << %[<meta property="og:site_name" content="#{escape_html(@title)}">\n]
779
+ if is_post && post_data[:"post.pubdate"]
780
+ content << %[<meta property="article:published_time" content="#{post_data[:"post.pubdate"]}">\n]
781
+ end
782
+ end
783
+
784
+ # Twitter Card meta tags
785
+ if platforms.include?("twitter")
786
+ content << %[<meta name="twitter:card" content="summary">\n]
787
+ content << %[<meta name="twitter:title" content="#{escape_html(title)}">\n]
788
+ content << %[<meta name="twitter:description" content="#{escape_html(description)}">\n]
789
+ content << %[<meta name="twitter:url" content="#{url}">\n]
790
+ end
791
+
792
+ content
793
+ end
794
+
795
+ def generate_reddit_button(post_data = nil)
796
+ # Check if Reddit is enabled in social config
797
+ social_config_file = @dir/:config/"social.txt"
798
+ return "" unless File.exist?(social_config_file)
799
+
800
+ social_config = read_commented_file(social_config_file)
801
+ reddit_enabled = social_config.any? { |line| line.strip.downcase == "reddit" }
802
+ return "" unless reddit_enabled
803
+
804
+ # Check if Reddit button is enabled
805
+ reddit_config_file = @dir/:config/"reddit.txt"
806
+ return "" unless File.exist?(reddit_config_file)
807
+
808
+ reddit_config = read_commented_file(reddit_config_file)
809
+ button_enabled = false
810
+ subreddit = ""
811
+ hover_text = ""
812
+
813
+ reddit_config.each do |line|
814
+ component, args = line.split(/\s+/, 2)
815
+ case component.downcase
816
+ when "button"
817
+ button_enabled = (args&.downcase == "true")
818
+ when "subreddit"
819
+ subreddit = args&.strip || ""
820
+ when "hover_text"
821
+ hover_text = args&.strip || ""
822
+ end
823
+ end
824
+
825
+ return "" unless button_enabled
826
+
827
+ # Determine post URL and title
828
+ if post_data
829
+ title = post_data[:"post.title"] || @title
830
+ slug = post_data[:"post.slug"] || slugify(post_data[:"post.id"], title)
831
+ url = "posts/#{slug}#{slug.end_with?('.html') ? '' : '.html'}"
832
+ else
833
+ title = @title
834
+ url = "index.html"
835
+ end
836
+
837
+ # Build Reddit share URL
838
+ require 'uri'
839
+ encoded_title = URI.encode_www_form_component(title)
840
+ if subreddit.empty?
841
+ reddit_url = "https://reddit.com/submit?url=#{escape_html(url)}&title=#{encoded_title}"
842
+ else
843
+ reddit_url = "https://reddit.com/r/#{subreddit}/submit?url=#{escape_html(url)}&title=#{encoded_title}"
844
+ end
845
+
846
+ # Determine hover text
847
+ if hover_text.empty?
848
+ hover_text = subreddit.empty? ? "Share on Reddit" : "Share on r/#{subreddit}"
849
+ end
850
+
851
+ # Generate button HTML (use production-relative ../../assets for posts context)
852
+ button_html = %[<a href="#{reddit_url}" target="_blank" title="#{hover_text}" style="text-decoration: none; margin-right: 8px;">
853
+ <img src="../../assets/icons/social/reddit.png" width="32" height="32" alt="Share on Reddit" style="vertical-align: middle;">
854
+ </a>]
855
+
856
+ button_html
857
+ end
858
+
859
+ def build_containers
860
+ sections = read_layout
861
+ content = ""
862
+ content << build_header(sections)
863
+ content << "<!-- before left/main/right -->\n"
864
+ content << "<div style='display: flex; flex-grow: 1; height: 100%; flex-direction: row;'>"
865
+ content << build_left(sections)
866
+ content << build_main(sections)
867
+ content << build_right(sections)
868
+ content << "</div> <!-- after left/main/right --></div>\n"
869
+ content << build_footer(sections)
870
+ content
871
+ end
872
+
873
+ def pagination_bar(group, count, nth) # nth group of total 'count'
874
+ str = %[<div style="text-align: center;">Pages: ]
875
+ 1.upto(count) do |i|
876
+ if i == nth # 0-based
877
+ str << "<b>[#{i}]</b>&nbsp;&nbsp;"
878
+ else
879
+ str << %[<a href="javascript:void(0)" style="text-decoration: none;"
880
+ onclick="load_main('page#{i}.html')">#{i}&nbsp;&nbsp;</a>]
881
+ end
882
+ end
883
+ str << "<br><br></div>"
884
+ end
885
+
886
+ def paginate_posts
887
+ config = read_post_index_config
888
+ posts = @repo.all_posts(self)
889
+ posts.sort! {|a,b| post_compare(a, b) }
890
+ @ppp ||= config[:"posts.per.page"].to_i
891
+ pages = []
892
+ posts.each_slice(@ppp).with_index do |group, i|
893
+ pages << group.map {|post| post_index_entry(post) }
894
+ end
895
+ out = self.dir/:output
896
+ pages.each.with_index do |page, i|
897
+ bar = pagination_bar(page, pages.size, i+1)
898
+ # Wrap rows in a table to preserve layout (match non-paginated index)
899
+ margin_top = config[:"index.margin.top"] || "0px"
900
+ cellpadding = config[:'entry.cellpadding']
901
+ table_open = %(<table width=100% cellpadding=#{cellpadding} style="margin-top: #{margin_top};">)
902
+ table_close = %(</table>)
903
+ html = String.new
904
+ html << table_open
905
+ html << page.join
906
+ html << table_close
907
+ html << %[<div style="position: absolute; bottom: 0; width: 100%;">#{bar}</div>]
908
+ write_file(out/"page#{i+1}.html", html)
909
+ end
910
+ # Remove existing link if it exists, then create new one
911
+ post_index_link = out/"post_index.html"
912
+ File.delete(post_index_link) if File.exist?(post_index_link)
913
+ FileUtils.ln(out/"page1.html", post_index_link)
914
+ end
915
+
916
+ def generate_front_page
917
+ index_file = @dir/:output/"index.html"
918
+ FileUtils.mkdir_p(File.dirname(index_file))
919
+
920
+ html_head = generate_html_head(true)
921
+ content = build_containers
922
+ common = get_common_js
923
+ boot = generate_bootstrap_js
924
+ highlight_js = generate_highlight_js(true)
925
+ highlight_ruby_js = generate_highlight_ruby_js(true)
926
+ full_html = <<~HTML
927
+ <!DOCTYPE html>
928
+ #{html_head}
929
+ <html style="height: 100%; margin: 0;">
930
+ <body style="height: 100%; margin: 0; display: flex; flex-direction: column;">
931
+ #{content.strip}
932
+ #{boot.strip}
933
+ #{highlight_js.strip}
934
+ #{highlight_ruby_js.strip}
935
+ #{common.strip}
936
+ </body>
937
+ </html>
938
+ HTML
939
+
940
+ # Beautify HTML if HtmlBeautifier is available
941
+ begin
942
+ full_html = ::HtmlBeautifier.beautify(full_html)
943
+ rescue NameError, LoadError => e
944
+ # HtmlBeautifier not available, continue without beautification
945
+ # This is not critical for functionality
946
+ end
947
+
948
+ # Write the main index file
949
+ write_file(index_file, full_html)
950
+
951
+ # Write debug file (optional, don't fail if it doesn't work)
952
+ begin
953
+ write_file("/tmp/full.html", full_html)
954
+ rescue => e
955
+ # Debug file write failed, but this is not critical
956
+ end
957
+
958
+ # Copy pages directory to output if it exists
959
+ pages_source = @dir/:pages
960
+ pages_output = @dir/:output/:pages
961
+ if Dir.exist?(pages_source)
962
+ FileUtils.mkdir_p(pages_output)
963
+ Dir.glob(pages_source/"*").each do |file|
964
+ next unless File.file?(file)
965
+ FileUtils.cp(file, pages_output/File.basename(file))
966
+ end
967
+ end
968
+ end
969
+
970
+ def generate_highlight_css(view = nil)
971
+ global_highlight = @root/:config/"highlight_css.txt"
972
+ view_highlight = @dir/:config/"highlight_css.txt"
973
+ highlight_file = view ? view_highlight : global_highlight
974
+ lines = read_commented_file(highlight_file)
975
+ href = rel = nil
976
+ lines.each do |line|
977
+ component, args = line.split(/\s+/, 2)
978
+ case component.downcase
979
+ when "href"
980
+ href = args
981
+ when "rel"
982
+ rel = args
983
+ end
984
+ end
985
+ %[<link rel="#{rel}" href="#{href}">\n]
986
+ end
987
+
988
+ def generate_highlight_js(view = nil)
989
+ global_highlight = @root/:config/"highlight_js.txt"
990
+ view_highlight = @dir/:config/"highlight_js.txt"
991
+ highlight_file = view ? view_highlight : global_highlight
992
+ lines = read_commented_file(highlight_file)
993
+ src = nil
994
+ lines.each do |line|
995
+ component, args = line.split(/\s+/, 2)
996
+ case component.downcase
997
+ when "src"
998
+ src = args
999
+ end
1000
+ end
1001
+ %[<script src="#{src}"></script>\n]
1002
+ end
1003
+
1004
+ def generate_highlight_ruby_js(view = nil)
1005
+ global_highlight = @root/:config/"highlight_ruby_js.txt"
1006
+ view_highlight = @dir/:config/"highlight_ruby_js.txt"
1007
+ highlight_file = view ? view_highlight : global_highlight
1008
+ lines = read_commented_file(highlight_file)
1009
+ src = nil
1010
+ lines.each do |line|
1011
+ component, args = line.split(/\s+/, 2)
1012
+ case component.downcase
1013
+ when "src"
1014
+ src = args
1015
+ end
1016
+ end
1017
+ %[<script src="#{src}"></script>\n]
1018
+ end
1019
+
1020
+
1021
+ end