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,976 @@
1
+ require 'fileutils'
2
+ require_relative 'syntax_highlighter'
3
+
4
+ class Scriptorium::View
5
+ include Scriptorium::Exceptions
6
+ include Scriptorium::Helpers
7
+ include Scriptorium::Contract
8
+
9
+ attr_reader :name, :title, :subtitle, :theme, :dir
10
+
11
+ def self.create_sample_view(repo)
12
+ repo.create_view("sample", "My first view", "This is just a sample")
13
+ # repo.generate_front_page("sample")
14
+ end
15
+
16
+ # Invariants
17
+ def define_invariants
18
+ invariant { @name.is_a?(String) && !@name.empty? }
19
+ invariant { @title.is_a?(String) && !@title.empty? }
20
+ invariant { @subtitle.is_a?(String) }
21
+ invariant { @theme.is_a?(String) && !@theme.empty? }
22
+ invariant { @root.is_a?(String) && !@root.empty? }
23
+ invariant { @repo.is_a?(Scriptorium::Repo) }
24
+ invariant { @dir.is_a?(String) && !@dir.empty? }
25
+ end
26
+
27
+ def initialize(name, title, subtitle = "", theme = "standard")
28
+ assume { name.is_a?(String) }
29
+ assume { title.is_a?(String) }
30
+ assume { subtitle.is_a?(String) }
31
+ assume { theme.is_a?(String) }
32
+
33
+ validate_name(name)
34
+ validate_title(title)
35
+
36
+ @name, @title, @subtitle, @theme = name, title, subtitle, theme
37
+ @root = Scriptorium::Repo.root
38
+ @repo = Scriptorium::Repo.repo
39
+ @dir = "#@root/views/#{name}"
40
+ @predef = Scriptorium::StandardFiles.new
41
+
42
+ define_invariants
43
+ verify { @name == name }
44
+ verify { @title == title }
45
+ check_invariants
46
+ end
47
+
48
+ def inspect
49
+ "<View: #@name #{@title.inspect} theme: #@theme>"
50
+ end
51
+
52
+ private def validate_name(name)
53
+ raise CannotCreateViewNameNil if name.nil?
54
+
55
+ raise CannotCreateViewNameEmpty if name.to_s.strip.empty?
56
+
57
+ unless name.match?(/^[a-zA-Z0-9_-]+$/)
58
+ raise CannotCreateViewNameInvalid(name)
59
+ end
60
+ end
61
+
62
+ private def validate_title(title)
63
+ raise CannotCreateViewTitleNil if title.nil?
64
+
65
+ raise CannotCreateViewTitleEmpty if title.to_s.strip.empty?
66
+ end
67
+
68
+ =begin
69
+ 1. The theme provides layout/config/header.txt with default content instructions.
70
+ 2. When the theme is applied, header.txt is copied to views/VIEW/config/.
71
+ 3. A placeholder layout/header.html is created in views/VIEW/layout/ with <!-- HEADER CONTENT -->.
72
+ 4. The file views/VIEW/config/header.txt is parsed to generate actual HTML.
73
+ 5. That HTML replaces the placeholder and is written to views/VIEW/output/panes/header.html.
74
+ 6. Later, output/panes/header.html is included when assembling views/VIEW/output/index.html.
75
+
76
+ That process is clean and logical. I see only minor points worth considering:
77
+
78
+ Copying header.txt from theme to view config/ is irreversible by design—once copied,
79
+ any theme updates won’t affect the view’s header.txt. That’s good for isolation, but
80
+ it might be worth exposing a way to “reapply” or “sync” a theme’s layout/config/
81
+ if desired.
82
+ Placeholder files like layout/header.html in layout/ may be unnecessary once
83
+ output/panes/header.html is reliably generated. If they exist solely for the
84
+ <!-- CONTENT --> tags, consider templating that in-memory instead.
85
+ You may want to enforce (or warn) if config/header.txt is missing or invalid
86
+ at generation time, to catch misconfigured views.
87
+ If you add more optional components (like navbars, banners, etc.), consider
88
+ adding light validation or doc comments to header.txt to aid future users/editors.
89
+
90
+ But overall, the process is robust and well thought-out. No major changes needed.
91
+ =end
92
+
93
+ def read_layout
94
+ layout_file = @dir/:config/"layout.txt"
95
+
96
+ need(:file, layout_file, LayoutFileMissing)
97
+
98
+ lines = read_commented_file(layout_file)
99
+ containers = {}
100
+ secs = []
101
+ lines.each do |line|
102
+ sec, args = line.split(/\s+/, 2)
103
+ containers[sec] = (args || "")
104
+ secs << sec
105
+ end
106
+ directives = %w[header footer left right main]
107
+ secs.each {|sec| raise LayoutHasUnknownTag(sec) unless directives.include?(sec)}
108
+ directives.each {|sec| raise LayoutHasDuplicateTags(sec) if lines.count(sec) > 1}
109
+ containers
110
+ end
111
+
112
+ def generate_empty_containers
113
+ layout_file = @dir/:config/"layout.txt"
114
+ return unless File.exist?(layout_file)
115
+
116
+ flexing = {
117
+ header: %[id="header" class="header" style="padding: 10px;"],
118
+ footer: %[class="footer" style="background: lightgray; padding: 10px;"],
119
+ left: %[class="left" style="width: %{width}; background: #f0f0f0; padding: 10px; flex-grow: 0; flex-shrink: 0;"],
120
+ right: %[class="right" style="width: %{width}; background: #f0f0f0; padding: 10px; flex-grow: 0; flex-shrink: 0;"],
121
+ main: %[class="main" style="flex-grow: 1; padding: 10px;"]
122
+ }
123
+ sections = read_layout
124
+ lines = sections.keys
125
+ # FIXME Pleeeease refactor this.
126
+ lines.each do |section|
127
+ args = sections[section] # like 20% for right, left
128
+ filename = @dir/:layout/"#{section}.html"
129
+ tag = section # header, footer, main
130
+ tag = "aside" if section == 'left' || tag == 'right'
131
+
132
+ inline = flexing[section.to_sym]
133
+ if section == "left" || section == "right"
134
+ mod = {width: args}
135
+ inline = inline % mod
136
+ end
137
+ content = <<~HTML
138
+ <#{tag} #{inline}>
139
+ <!-- Section: #{section} -->
140
+ </#{tag}>
141
+ HTML
142
+
143
+ write_file(filename, content)
144
+ end
145
+ end
146
+
147
+ def theme(change = nil)
148
+ return @theme if change.nil?
149
+ # what if it doesn't exist?
150
+ need(:dir, @root/:themes/change, ThemeDoesntExist)
151
+ @theme = change
152
+ change_config(@dir/"config.txt", "theme", change)
153
+ apply_theme(change)
154
+ end
155
+
156
+ def apply_theme(theme)
157
+ check_invariants
158
+ assume { theme.is_a?(String) && !theme.empty? }
159
+
160
+ # check to see if ever done before?
161
+ # copy layout.txt to view
162
+ t = Scriptorium::Theme.new(@root, theme)
163
+ need(:file, t.file("layout.txt"), ThemeFileNotFound)
164
+ FileUtils.cp(t.file("layout.txt"), @dir/:config)
165
+ # copy other .txt to view? header, footer, ...
166
+ names = %w[header footer left right main]
167
+ lay = @root/:themes/theme/:layout
168
+ names.each do |name|
169
+ f1, f2 = lay/:config/"#{name}.txt", dir/:config
170
+ need(:file, f1, ThemeFileNotFound)
171
+ FileUtils.cp(f1, f2)
172
+ end
173
+ generate_empty_containers
174
+
175
+ verify { @theme == theme }
176
+ check_invariants
177
+ end
178
+
179
+ def content_tag(section)
180
+ "<!-- Section: #{section} -->"
181
+ end
182
+
183
+ def placeholder_text(str)
184
+ if str.start_with?("@")
185
+ file = @dir/:config/:text/"#{str[1..]}"
186
+ read_file(file, missing_fallback: "[Missing: #{file}]")
187
+ else
188
+ str
189
+ end
190
+ end
191
+
192
+ def section_append(sec, str)
193
+ file = @dir/:config/"#{sec}.txt"
194
+ text = read_file(file)
195
+ text << str
196
+ write_file(file, text)
197
+ end
198
+
199
+ def section_hash(section)
200
+ hash = Hash.new { |hash, key| ->(arg = nil) { "<!-- Not defined for key: #{key} -->\n" } }
201
+ hash["text"] = ->(arg) { " <p>" + placeholder_text(arg) + "</p>\n" }
202
+ hash
203
+ end
204
+
205
+ def section_core(section, hash)
206
+ cfg = @dir/:config
207
+ template = @dir/:layout/"#{section}.html"
208
+ sectxt = cfg/"#{section}.txt"
209
+
210
+ # Only add placeholder if section has no real content
211
+ lines = read_commented_file(sectxt)
212
+ if lines.empty? && section != "main"
213
+ section_append(section, "\ntext This is #{section}...")
214
+ lines = read_commented_file(sectxt)
215
+ end
216
+
217
+ result = "<!-- Section: #{section} (output) -->\n"
218
+ lines.each do |line|
219
+ component, arg = line.split(/\s+/, 2)
220
+
221
+ # Handle malformed config lines
222
+ if component.nil? || component.strip.empty?
223
+ result << "<!-- Invalid config line: #{line.inspect} -->\n"
224
+ next
225
+ end
226
+
227
+ component = component.downcase
228
+ if hash.key?(component)
229
+ result << hash[component].call(arg)
230
+ else
231
+ result << "<!-- Unknown component: #{component} -->\n"
232
+ end
233
+ end
234
+ result
235
+ end
236
+
237
+ =begin
238
+ To build a header, I start with two things:
239
+ config/header.txt (which is user-supplied and has things such as "title" in it); and
240
+ layout/header.html (which is a template with <header> tags enclosing at least a line
241
+ like "<!-- Section: header -->"
242
+
243
+ get core: I process header.txt line by line, gathering the "core" or "guts" of the header.
244
+ sub into template: I substitute this into the template contents and
245
+ write output: write the result to output/panes/header.html
246
+ =end
247
+
248
+ def build_section(section, hash2 = {}, args = "")
249
+ config = @dir/:config/"#{section}.txt"
250
+ template = @dir/:layout/"#{section}.html"
251
+ output = @dir/:output/:panes/"#{section}.html"
252
+
253
+ # Ensure output directory exists
254
+ FileUtils.mkdir_p(File.dirname(output))
255
+
256
+ # Check if template exists
257
+ need(:file, template)
258
+
259
+ hash = section_hash(section)
260
+ hash.merge!(hash2)
261
+ core = section_core(section, hash)
262
+
263
+ temp_txt = read_file(template)
264
+
265
+ target = content_tag(section)
266
+ temp_txt.sub!(target, core)
267
+
268
+ begin
269
+ write_file(output, temp_txt)
270
+ rescue Errno::EACCES, Errno::ENOSPC => e
271
+ raise SectionOutputError(output, section, e.message)
272
+ end
273
+
274
+ html = read_file(output)
275
+ html
276
+ end
277
+
278
+ def build_header(sections)
279
+ args = sections["header"]
280
+ return "" unless args
281
+ h2 = {
282
+ "title" => ->(arg = nil) { " <h1>#{escape_html(@title)}</h1>" },
283
+ "subtitle" => ->(arg = nil) { " <p>#{escape_html(@subtitle)}</p>" },
284
+ "nav" => ->(arg = nil) { build_nav(arg) },
285
+ "banner" => ->(arg = nil) { build_banner(arg) }
286
+ }
287
+
288
+ build_section("header", h2, args)
289
+ end
290
+
291
+ ### Helpers for header
292
+
293
+ def build_banner(arg)
294
+ # Check if this is an SVG banner request
295
+ return build_banner_svg_from_file if arg == "svg"
296
+
297
+ # Otherwise, treat as image filename
298
+ return build_banner_image(arg)
299
+ end
300
+
301
+ def build_banner_svg_from_file
302
+ bsvg = Scriptorium::BannerSVG.new(@title, @subtitle)
303
+
304
+ # Look for svg.txt file in the view's config directory
305
+ svg_config_file = @dir/:config/"svg.txt"
306
+ if File.exist?(svg_config_file)
307
+ bsvg.parse_header_svg(svg_config_file)
308
+ else
309
+ # No svg.txt file, use defaults
310
+ bsvg.parse_header_svg
311
+ end
312
+
313
+ bsvg.get_svg
314
+ end
315
+
316
+ def build_banner_image(image_filename)
317
+ # Search for image in multiple locations
318
+ image_paths = [
319
+ @dir/:assets/image_filename, # view/assets/
320
+ @repo.root/:assets/image_filename, # repo/assets/
321
+ ]
322
+
323
+ # Find the first existing image
324
+ image_path = image_paths.find { |path| File.exist?(path) }
325
+
326
+ if image_path
327
+ # Use relative path for the img src
328
+ if image_path.to_s.start_with?(@dir.to_s)
329
+ # Image is in view directory, use relative path
330
+ relative_path = image_path.to_s.sub(@dir.to_s + "/", "")
331
+ else
332
+ # Image is in repo directory, use relative path from view
333
+ relative_path = "../assets/#{image_filename}"
334
+ end
335
+ html = %[<img src='#{relative_path}' alt='Banner Image' style='width: 100%; height: auto;' />]
336
+ return html
337
+ else
338
+ # Try to copy from global assets
339
+ global_assets_dir = @repo.root/:assets
340
+ global_image_path = global_assets_dir/image_filename
341
+
342
+ if File.exist?(global_image_path)
343
+ # Copy to view assets
344
+ view_assets_dir = @dir/:assets
345
+ make_dir(view_assets_dir) unless Dir.exist?(view_assets_dir)
346
+ FileUtils.cp(global_image_path, view_assets_dir/image_filename)
347
+
348
+ # Use relative path
349
+ relative_path = "assets/#{image_filename}"
350
+ html = %[<img src='#{relative_path}' alt='Banner Image' style='width: 100%; height: auto;' />]
351
+ return html
352
+ else
353
+ # Image not found anywhere
354
+ html = %[<p>Banner image missing: #{image_filename}</p>]
355
+ return html
356
+ end
357
+ end
358
+ end
359
+
360
+ def build_banner_svg(arg)
361
+ bsvg = Scriptorium::BannerSVG.new(@title, @subtitle)
362
+
363
+ # Look for config file in the view's config directory
364
+ config_file = @dir/:config/"config.txt"
365
+ if File.exist?(config_file)
366
+ bsvg.parse_header_svg(config_file)
367
+ else
368
+ # No config file, just use defaults
369
+ bsvg.parse_header_svg
370
+ end
371
+
372
+ code = bsvg.get_svg
373
+ end
374
+
375
+ def build_nav(arg)
376
+ # Determine navbar file - if no arg, use navbar.txt, otherwise use specified file
377
+ nav_file = if arg.nil? || arg.strip.empty?
378
+ @dir/:config/"navbar.txt"
379
+ else
380
+ @dir/:config/"#{arg}"
381
+ end
382
+
383
+ # Read navbar content with fallback for missing files
384
+ nav_content = read_file(nav_file, missing_fallback: "<p>Navigation not available</p>")
385
+
386
+ # Parse and generate Bootstrap navbar
387
+ generate_bootstrap_navbar(nav_content)
388
+ end
389
+
390
+ def generate_bootstrap_navbar(nav_content)
391
+ menu_items = parse_navbar_content(nav_content)
392
+
393
+ # Generate Bootstrap navbar HTML
394
+ html = <<~HTML
395
+ <nav class="navbar navbar-expand-lg navbar-light bg-light">
396
+ <div class="container-fluid">
397
+ <button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav" aria-controls="navbarNav" aria-expanded="false" aria-label="Toggle navigation">
398
+ <span class="navbar-toggler-icon"></span>
399
+ </button>
400
+ <div class="collapse navbar-collapse" id="navbarNav">
401
+ <ul class="navbar-nav">
402
+ #{generate_navbar_items(menu_items)}
403
+ </ul>
404
+ </div>
405
+ </div>
406
+ </nav>
407
+ HTML
408
+
409
+ html
410
+ end
411
+
412
+ def parse_navbar_content(content)
413
+ menu_items = []
414
+ current_dropdown = nil
415
+
416
+ content.lines.each do |line|
417
+ line = line.rstrip # Keep leading spaces, remove trailing
418
+ next if line.empty? || line.start_with?('#')
419
+
420
+ if line.start_with?('=')
421
+ # Top-level dropdown item
422
+ label = line[1..-1].strip
423
+ current_dropdown = { type: :dropdown, label: label, children: [] }
424
+ menu_items << current_dropdown
425
+ elsif line.start_with?(' ')
426
+ # Child of previous dropdown
427
+ if current_dropdown
428
+ # Remove leading spaces and split on multiple spaces
429
+ clean_line = line.strip
430
+ if clean_line.include?(' ') # Look for multiple spaces
431
+ parts = clean_line.split(/\s{2,}/, 2) # Split on 2+ spaces
432
+ if parts.length >= 2
433
+ title, filename = parts[0], parts[1]
434
+ current_dropdown[:children] << { type: :child, title: title, filename: filename }
435
+ end
436
+ end
437
+ end
438
+ elsif line.start_with?('-')
439
+ # Top-level item (no children)
440
+ clean_line = line[1..-1].strip
441
+ if clean_line.include?(' ') # Look for multiple spaces
442
+ parts = clean_line.split(/\s{2,}/, 2) # Split on 2+ spaces
443
+ if parts.length >= 2
444
+ title, filename = parts[0], parts[1]
445
+ menu_items << { type: :item, title: title, filename: filename }
446
+ end
447
+ end
448
+ end
449
+ end
450
+
451
+ menu_items
452
+ end
453
+
454
+ def generate_navbar_items(menu_items)
455
+ html = ""
456
+
457
+ menu_items.each do |item|
458
+ case item[:type]
459
+ when :dropdown
460
+ html << generate_dropdown_item(item)
461
+ when :item
462
+ html << generate_nav_item(item)
463
+ end
464
+ end
465
+
466
+ html
467
+ end
468
+
469
+ def generate_dropdown_item(item)
470
+ dropdown_id = "dropdown-#{item[:label].downcase.gsub(/\s+/, '-')}"
471
+
472
+ html = <<~HTML
473
+ <li class="nav-item dropdown">
474
+ <a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown" aria-expanded="false">
475
+ #{escape_html(item[:label])}
476
+ </a>
477
+ <ul class="dropdown-menu">
478
+ HTML
479
+
480
+ item[:children].each do |child|
481
+ html << generate_dropdown_child(child)
482
+ end
483
+
484
+ html << <<~HTML
485
+ </ul>
486
+ </li>
487
+ HTML
488
+
489
+ html
490
+ end
491
+
492
+ def generate_dropdown_child(child)
493
+ link_url, warning = get_page_link(child[:filename])
494
+
495
+ html = <<~HTML
496
+ <li><a class="dropdown-item" href="javascript:void(0)" onclick="load_main('#{link_url}')">#{escape_html(child[:title])}</a></li>
497
+ HTML
498
+
499
+ html << "<!-- #{warning} -->\n" if warning
500
+
501
+ html
502
+ end
503
+
504
+ def generate_nav_item(item)
505
+ link_url, warning = get_page_link(item[:filename])
506
+
507
+ html = <<~HTML
508
+ <li class="nav-item">
509
+ <a class="nav-link" href="javascript:void(0)" onclick="load_main('#{link_url}')">#{escape_html(item[:title])}</a>
510
+ </li>
511
+ HTML
512
+
513
+ html << "<!-- #{warning} -->\n" if warning
514
+
515
+ html
516
+ end
517
+
518
+ def get_page_link(filename)
519
+ # Check if the page file exists
520
+ page_file = @dir/:pages/"#{filename}.html"
521
+
522
+ if File.exist?(page_file)
523
+ # Page exists, return relative path
524
+ link_url = "pages/#{filename}.html"
525
+ warning = nil
526
+ else
527
+ # Page doesn't exist, still create link but warn
528
+ link_url = "pages/#{filename}.html"
529
+ warning = "Warning: Page file '#{filename}.html' not found in pages directory"
530
+ end
531
+
532
+ [link_url, warning]
533
+ end
534
+
535
+ def build_widgets(arg)
536
+ check_invariants
537
+ assume { arg.is_a?(String) }
538
+ validate_widget_arg(arg)
539
+
540
+ widgets = arg.split
541
+ content = ""
542
+ widgets.each do |widget|
543
+ validate_widget_name(widget)
544
+
545
+ widget_class = eval("Scriptorium::Widget::#{widget.capitalize}")
546
+ obj = widget_class.new(@repo, self)
547
+ obj.generate
548
+ content << obj.card
549
+ end
550
+ verify { content.is_a?(String) }
551
+ check_invariants
552
+ content
553
+ end
554
+
555
+ private def validate_widget_arg(arg)
556
+ raise CannotBuildWidgetsArgNil if arg.nil?
557
+
558
+ raise CannotBuildWidgetsArgEmpty if arg.to_s.strip.empty?
559
+ end
560
+
561
+ private def validate_widget_name(name)
562
+ raise CannotBuildWidgetNameNil if name.nil? || name.strip.empty?
563
+
564
+ unless name.match?(/^[a-zA-Z0-9_]+$/)
565
+ raise CannotBuildWidgetNameInvalid(name)
566
+ end
567
+ end
568
+
569
+ ###
570
+
571
+ def build_footer(sections)
572
+ args = sections["footer"]
573
+ return "" unless args
574
+ build_section("footer", {}, args)
575
+ end
576
+
577
+ def build_left(sections)
578
+ args = sections["left"]
579
+ return "" unless args
580
+ h2 = { "widget" => ->(arg = nil) { build_widgets(arg) } }
581
+ build_section("left", h2, args)
582
+ end
583
+
584
+ def build_right(sections)
585
+ args = sections["right"]
586
+ return "" unless args
587
+ h2 = { "widget" => ->(arg = nil) { build_widgets(arg) } }
588
+ build_section("right", h2, args)
589
+ end
590
+
591
+ def build_main(sections)
592
+ args = sections["main"]
593
+ return "" unless args
594
+ html = " <!-- Section: main (output) -->\n"
595
+ html << %[ <div id="main" class="main" style="flex-grow: 1; padding: 10px; overflow-y: auto; position: relative; display: flex; flex-direction: column;">]
596
+ # html << %[<div id="main" class="main" style="position: relative; display: flex; flex-direction: column;">\n]
597
+ html << @predef.post_index_style
598
+ if view_posts.empty?
599
+ html << " <h1>No posts yet!</h1>"
600
+ else
601
+ paginate_posts
602
+ need(:file, self.dir/:output/"post_index.html")
603
+ html << read_file(self.dir/:output/"post_index.html")
604
+ end
605
+ html << "</div> <!-- end main -->\n"
606
+ end
607
+
608
+ def generate_post_index
609
+ posts = @repo.all_posts(self)
610
+ str = ""
611
+ # FIXME - many decisions to make here...
612
+ posts.each do |post|
613
+ str << post_index_entry(post)
614
+ end
615
+ write_file(@dir/:output/"post_index.html", str)
616
+ end
617
+
618
+ def post_index_entry(post)
619
+ # grab index-entry template
620
+ # generate index-entry for each post
621
+ # append to str
622
+ num, title, pubdate, blurb = post.attrs(:id, :title, :pubdate, :blurb)
623
+ template = @predef.index_entry
624
+ entry = substitute(post, template)
625
+ entry
626
+ end
627
+
628
+ def post_index_array
629
+ posts = view_posts.sort {|a,b| cf_time(b.pubdate, a.pubdate) }
630
+ posts.map {|post| post_index_entry(post)}
631
+ end
632
+
633
+ def view_posts
634
+ posts = []
635
+ @repo.all_posts(self).sort_by {|post| post.pubdate}
636
+ end
637
+
638
+ def generate_html_head(view = nil)
639
+ # FIXME - view does not yet override global
640
+ global_head = @root/:config/"global-head.txt"
641
+ view_head = @dir/:config/"global-head.txt"
642
+ head_file = view ? view_head : global_head
643
+ which = view ? "view" : "global"
644
+ line1 = "<!-- head info from #{which} -->"
645
+ lines = read_commented_file(head_file)
646
+ content = "<head>\n#{line1}\n<title>#{@title}</title>\n"
647
+ lines.each do |line|
648
+ component, args = line.split(/\s+/, 2)
649
+ case component.downcase
650
+ when "charset"
651
+ @charset = args
652
+ content << %[<meta charset="#{args}">\n]
653
+ when "desc"
654
+ @desc = args
655
+ content << %[<meta name="description" content="#{args}">\n]
656
+ when "viewport"
657
+ @viewport = args
658
+ str = args.split.join(" ")
659
+ content << %[<meta name="viewport" content="#{str}">\n]
660
+ when "robots"
661
+ @robots = args
662
+ str = args.split.join(", ")
663
+ content << %[<meta name="robots" content="#{str}">\n]
664
+ # when "javascript"
665
+ # content << get_common_js(view)
666
+ when "bootstrap"
667
+ content << generate_bootstrap_css(view)
668
+ when "social"
669
+ content << generate_social_meta_tags(args)
670
+ when "syntax"
671
+ content << generate_syntax_css
672
+ end
673
+ end
674
+ content << "</head>\n"
675
+ content
676
+ end
677
+
678
+
679
+
680
+ def get_common_js(view = nil)
681
+ global_js = @root/:config/"common.js"
682
+ view_js = @dir/:config/"common.js"
683
+ js_file = view ? view_js : global_js
684
+ code = read_file(js_file)
685
+ return %[<script>#{code}</script>\n]
686
+ end
687
+
688
+ def generate_bootstrap_css(view = nil)
689
+ global_boot = @root/:config/"bootstrap_css.txt"
690
+ view_boot = @dir/:config/"bootstrap_css.txt"
691
+ bs_file = view ? view_boot : global_boot
692
+ lines = read_commented_file(bs_file)
693
+ href = rel = integrity = crossorigin = nil
694
+ lines.each do |line|
695
+ component, args = line.split(/\s+/, 2)
696
+ case component.downcase
697
+ when "href"
698
+ href = args
699
+ when "rel"
700
+ rel = args
701
+ when "integrity"
702
+ integrity = args
703
+ when "crossorigin"
704
+ crossorigin = args
705
+ end
706
+ end
707
+ # content = %[<link rel="#{rel}" href="#{href}" integrity="#{integrity}" crossorigin="#{crossorigin}">\n]
708
+ content = %[<link rel="stylesheet" href="#{href}"></link>\n]
709
+ content
710
+ end
711
+
712
+
713
+
714
+ def generate_bootstrap_js(view = nil)
715
+ global_boot = @root/:config/"bootstrap_js.txt"
716
+ view_boot = @dir/:config/"bootstrap_js.txt"
717
+ bs_file = view ? view_boot : global_boot
718
+ lines = read_commented_file(bs_file)
719
+ src = integrity = crossorigin = nil
720
+ lines.each do |line|
721
+ component, args = line.split(/\s+/, 2)
722
+ case component.downcase
723
+ when "src"
724
+ src = args
725
+ when "rel"
726
+ rel = args
727
+ when "integrity"
728
+ integrity = args
729
+ when "crossorigin"
730
+ crossorigin = args
731
+ end
732
+ end
733
+ # content = %[<script src="#{src}" integrity="#{integrity}" crossorigin="#{crossorigin}"></script>\n]
734
+ content = %[<script src="#{src}"></script>\n]
735
+ content
736
+ end
737
+
738
+ def generate_social_meta_tags(args = nil, post_data = nil)
739
+ # Check if social is enabled for this view
740
+ social_config_file = @dir/:config/"social.txt"
741
+ return "" unless File.exist?(social_config_file)
742
+
743
+ # Read social configuration
744
+ social_config = read_commented_file(social_config_file)
745
+ platforms = []
746
+
747
+ # Each non-comment line is a platform name
748
+ social_config.each do |line|
749
+ platform = line.strip.downcase
750
+ platforms << platform if platform.match?(/^(facebook|twitter|linkedin|reddit)$/)
751
+ end
752
+
753
+ return "" if platforms.empty?
754
+
755
+ # Determine if this is for a specific post or the main page
756
+ is_post = !post_data.nil?
757
+
758
+ # Get the appropriate title, description, and URL
759
+ if is_post
760
+ title = post_data[:"post.title"] || @title
761
+ description = post_data[:"post.blurb"] || post_data[:"post.body"]&.truncate(200) || @desc || @subtitle || @title
762
+ url = "posts/#{post_data[:"post.slug"] || slugify(post_data[:"post.id"], title)}.html"
763
+ type = "article"
764
+ else
765
+ title = @title
766
+ description = @desc || @subtitle || @title
767
+ url = "index.html"
768
+ type = "website"
769
+ end
770
+
771
+ # Generate meta tags
772
+ content = ""
773
+
774
+ # Open Graph meta tags (Facebook, LinkedIn, etc.)
775
+ if platforms.include?("facebook") || platforms.include?("linkedin")
776
+ content << %[<meta property="og:title" content="#{escape_html(title)}">\n]
777
+ content << %[<meta property="og:type" content="#{type}">\n]
778
+ content << %[<meta property="og:url" content="#{url}">\n]
779
+ content << %[<meta property="og:description" content="#{escape_html(description)}">\n]
780
+ content << %[<meta property="og:site_name" content="#{escape_html(@title)}">\n]
781
+ if is_post && post_data[:"post.pubdate"]
782
+ content << %[<meta property="article:published_time" content="#{post_data[:"post.pubdate"]}">\n]
783
+ end
784
+ end
785
+
786
+ # Twitter Card meta tags
787
+ if platforms.include?("twitter")
788
+ content << %[<meta name="twitter:card" content="summary">\n]
789
+ content << %[<meta name="twitter:title" content="#{escape_html(title)}">\n]
790
+ content << %[<meta name="twitter:description" content="#{escape_html(description)}">\n]
791
+ content << %[<meta name="twitter:url" content="#{url}">\n]
792
+ end
793
+
794
+ content
795
+ end
796
+
797
+ def generate_reddit_button(post_data = nil)
798
+ # Check if Reddit is enabled in social config
799
+ social_config_file = @dir/:config/"social.txt"
800
+ return "" unless File.exist?(social_config_file)
801
+
802
+ social_config = read_commented_file(social_config_file)
803
+ reddit_enabled = social_config.any? { |line| line.strip.downcase == "reddit" }
804
+ return "" unless reddit_enabled
805
+
806
+ # Check if Reddit button is enabled
807
+ reddit_config_file = @dir/:config/"reddit.txt"
808
+ return "" unless File.exist?(reddit_config_file)
809
+
810
+ reddit_config = read_commented_file(reddit_config_file)
811
+ button_enabled = false
812
+ subreddit = ""
813
+ hover_text = ""
814
+
815
+ reddit_config.each do |line|
816
+ component, args = line.split(/\s+/, 2)
817
+ case component.downcase
818
+ when "button"
819
+ button_enabled = (args&.downcase == "true")
820
+ when "subreddit"
821
+ subreddit = args&.strip || ""
822
+ when "hover_text"
823
+ hover_text = args&.strip || ""
824
+ end
825
+ end
826
+
827
+ return "" unless button_enabled
828
+
829
+ # Determine post URL and title
830
+ if post_data
831
+ title = post_data[:"post.title"] || @title
832
+ url = "posts/#{post_data[:"post.slug"] || slugify(post_data[:"post.id"], title)}.html"
833
+ else
834
+ title = @title
835
+ url = "index.html"
836
+ end
837
+
838
+ # Build Reddit share URL
839
+ if subreddit.empty?
840
+ reddit_url = "https://reddit.com/submit?url=#{escape_html(url)}&title=#{escape_html(title)}"
841
+ else
842
+ reddit_url = "https://reddit.com/r/#{subreddit}/submit?url=#{escape_html(url)}&title=#{escape_html(title)}"
843
+ end
844
+
845
+ # Determine hover text
846
+ if hover_text.empty?
847
+ hover_text = subreddit.empty? ? "Share on Reddit" : "Share on r/#{subreddit}"
848
+ end
849
+
850
+ # Generate button HTML
851
+ button_html = %[<a href="#{reddit_url}" target="_blank" title="#{hover_text}" style="text-decoration: none; margin-right: 8px;">
852
+ <img src="assets/reddit-logo.png" width="16" height="16" alt="Share on Reddit" style="vertical-align: middle;">
853
+ </a>]
854
+
855
+ button_html
856
+ end
857
+
858
+ def build_containers
859
+ sections = read_layout
860
+ content = ""
861
+ content << build_header(sections)
862
+ content << "<!-- before left/main/right -->\n"
863
+ content << "<div style='display: flex; flex-grow: 1; height: 100%; flex-direction: row;'>"
864
+ content << build_left(sections)
865
+ content << build_main(sections)
866
+ content << build_right(sections)
867
+ content << "</div> <!-- after left/main/right --></div>\n"
868
+ content << build_footer(sections)
869
+ content
870
+ end
871
+
872
+ def pagination_bar(group, count, nth) # nth group of total 'count'
873
+ str = %[<div style="align-self: flex-end;">Pages: ]
874
+ 1.upto(count) do |i|
875
+ if i == nth # 0-based
876
+ str << "<b>[#{i}]</b>&nbsp;&nbsp;"
877
+ else
878
+ str << %[<a href="javascript:void(0)" style="text-decoration: none;"
879
+ onclick="load_main('page#{i}.html')">#{i}&nbsp;&nbsp;</a>]
880
+ end
881
+ end
882
+ str << "<br><br></div>"
883
+ end
884
+
885
+ def paginate_posts
886
+ posts = @repo.all_posts(self)
887
+ posts.sort! {|a,b| cf_time(b.pubdate, a.pubdate) }
888
+ ppp = 10 # FIXME posts per page
889
+ pages = []
890
+ posts.each_slice(ppp).with_index do |group, i|
891
+ pages << group.map {|post| post_index_entry(post) }
892
+ end
893
+ out = self.dir/:output
894
+ pages.each.with_index do |page, i|
895
+ bar = pagination_bar(page, pages.size, i+1)
896
+ page << %[<div style="position: absolute; bottom: 0; width: 100%;">#{bar}</div>]
897
+ write_file(out/"page#{i+1}.html", page.join)
898
+ end
899
+ # Remove existing link if it exists, then create new one
900
+ post_index_link = out/"post_index.html"
901
+ File.delete(post_index_link) if File.exist?(post_index_link)
902
+ FileUtils.ln(out/"page1.html", post_index_link)
903
+ end
904
+
905
+ def generate_front_page
906
+ layout_file = @dir/:config/"layout.txt"
907
+ index_file = @dir/:output/"index.html"
908
+ panes = @dir/:output/:panes
909
+
910
+ # Ensure output directory exists
911
+ FileUtils.mkdir_p(File.dirname(index_file))
912
+
913
+ html_head = generate_html_head(true)
914
+ content = build_containers
915
+ common = get_common_js
916
+ boot = generate_bootstrap_js
917
+ full_html = <<~HTML
918
+ <!DOCTYPE html>
919
+ #{html_head}
920
+ <html style="height: 100%; margin: 0;">
921
+ <body style="height: 100%; margin: 0; display: flex; flex-direction: column;">
922
+ #{content.strip}
923
+ #{boot.strip}
924
+ #{common.strip}
925
+ </body>
926
+ </html>
927
+ HTML
928
+
929
+ # Beautify HTML if HtmlBeautifier is available
930
+ # begin
931
+ # full_html = ::HtmlBeautifier.beautify(full_html)
932
+ # rescue NameError, LoadError => e
933
+ # # HtmlBeautifier not available, continue without beautification
934
+ # # This is not critical for functionality
935
+ # end
936
+
937
+ # Write the main index file
938
+ begin
939
+ write_file(index_file, full_html)
940
+ rescue Errno::ENOSPC, Errno::EACCES => e
941
+ raise FailedToWriteFrontPage(e.message)
942
+ end
943
+
944
+ # Write debug file (optional, don't fail if it doesn't work)
945
+ begin
946
+ write_file("/tmp/full.html", full_html)
947
+ rescue => e
948
+ # Debug file write failed, but this is not critical
949
+ end
950
+
951
+ # Copy pages directory to output if it exists
952
+ pages_source = @dir/:pages
953
+ pages_output = @dir/:output/:pages
954
+ if Dir.exist?(pages_source)
955
+ FileUtils.mkdir_p(pages_output)
956
+ Dir.glob(pages_source/"*").each do |file|
957
+ next unless File.file?(file)
958
+ FileUtils.cp(file, pages_output/File.basename(file))
959
+ end
960
+ end
961
+ end
962
+
963
+
964
+
965
+
966
+ def generate_syntax_css
967
+ highlighter = Scriptorium::SyntaxHighlighter.new
968
+ "<style>\n#{highlighter.generate_css}\n</style>\n"
969
+ end
970
+
971
+ def highlight_code(code, language = nil)
972
+ highlighter = Scriptorium::SyntaxHighlighter.new
973
+ highlighter.highlight(code, language)
974
+ end
975
+
976
+ end