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,624 @@
1
+ class Scriptorium::Repo
2
+ include Scriptorium::Exceptions
3
+ extend Scriptorium::Exceptions
4
+ include Scriptorium::Helpers
5
+ extend Scriptorium::Helpers
6
+ include Scriptorium::Contract
7
+ extend Scriptorium::Contract
8
+
9
+ class << self
10
+ attr_accessor :testing
11
+ attr_reader :root, :repo # class level
12
+ end
13
+
14
+ # instance attrs
15
+
16
+ attr_reader :root, :views, :current_view
17
+
18
+ def self.exist?
19
+ dir = Scriptorium::Repo.root
20
+ return false if dir.nil?
21
+ Dir.exist?(dir)
22
+ end
23
+
24
+ def self.create(path = nil, testmode: false)
25
+ assume { path.nil? || path.is_a?(String) }
26
+ # Handle backward compatibility: boolean true means testing mode
27
+ if testmode == true
28
+ Scriptorium::Repo.testing = path
29
+ else
30
+ Scriptorium::Repo.testing = nil
31
+ end
32
+ home = ENV['HOME']
33
+ @predef = Scriptorium::StandardFiles.new
34
+ @root = path || "#{home}/.scriptorium"
35
+ parent = path ? "." : home
36
+ file = path || ".scriptorium"
37
+ @root = parent/file
38
+ raise self.RepoDirAlreadyExists(@root) if Dir.exist?(@root)
39
+ make_tree(parent, <<~EOS)
40
+ #@root
41
+ ├── config/ # Global config files
42
+ ├── views/ # Views
43
+ ├── drafts/ # Draft posts (global)
44
+ ├── posts/ # Global generated posts (slug.html)
45
+ ├── assets/ # Images, etc.
46
+ │ └── library/ # Common images, icons, etc.
47
+ └── themes/ # Themes
48
+ EOS
49
+
50
+ postnum_file = "#@root/config/last_post_num.txt"
51
+ write_file(postnum_file, "0")
52
+ write_file(@root/:config/"global-head.txt", @predef.html_head_content)
53
+ write_file(@root/:config/"bootstrap_js.txt", @predef.bootstrap_js)
54
+ write_file(@root/:config/"bootstrap_css.txt", @predef.bootstrap_css)
55
+ write_file(@root/:config/"common.js", @predef.common_js)
56
+ write_file(@root/:config/"widgets.txt", @predef.available_widgets)
57
+ Scriptorium::Theme.create_standard(@root) # Theme: templates, etc.
58
+
59
+ # Copy application-wide gem assets to library
60
+ Scriptorium::Theme.copy_gem_assets_to_library(@root)
61
+
62
+ # Generate OS-specific helper code
63
+ generate_os_helpers(@root)
64
+
65
+ @repo = self.open(@root)
66
+ Scriptorium::View.create_sample_view(repo)
67
+ verify { @repo.is_a?(Scriptorium::Repo) }
68
+ return repo
69
+ end
70
+
71
+ def self.open(root)
72
+ assume { root.is_a?(String) && !root.empty? }
73
+ repo = Scriptorium::Repo.new(root)
74
+ verify { repo.is_a?(Scriptorium::Repo) }
75
+ repo
76
+ end
77
+
78
+ def self.destroy
79
+ assume { Scriptorium::Repo.testing }
80
+ raise self.TestModeOnly unless Scriptorium::Repo.testing
81
+ system!("rm -rf #@root", "destroying repository")
82
+ verify { !Dir.exist?(@root) }
83
+ end
84
+
85
+ def postnum_file
86
+ "#@root/config/last_post_num.txt"
87
+ end
88
+
89
+ # Invariants
90
+ def define_invariants
91
+ invariant { @root.is_a?(String) && !@root.empty? }
92
+ invariant { @views.is_a?(Array) }
93
+ invariant { @current_view.nil? || @current_view.is_a?(Scriptorium::View) }
94
+ end
95
+
96
+ def initialize(root) # repo
97
+ assume { root.is_a?(String) && !root.empty? }
98
+ @root = root
99
+ @predef = Scriptorium::StandardFiles.new
100
+ # Scriptorium::Repo.class_eval { @root, @repo = root, self }
101
+ self.class.instance_variable_set(:@root, root)
102
+ self.class.instance_variable_set(:@repo, self)
103
+ load_views
104
+ @reddit = nil # Lazy load Reddit integration
105
+ define_invariants
106
+ verify { @root == root }
107
+ check_invariants
108
+ end
109
+
110
+ private def load_views
111
+ @views = []
112
+ list = Dir.entries(@root/:views) - %w[. .. config.txt]
113
+ list.each {|dir| open_view(dir) }
114
+ cview_file = @root/:config/"currentview.txt"
115
+ @current_view = nil
116
+ if File.exist?(cview_file)
117
+ view_name = read_file(cview_file).chomp
118
+ begin
119
+ @current_view = lookup_view(view_name)
120
+ rescue => e
121
+ # If the saved view doesn't exist, just leave current_view as nil
122
+ # It will be set when a view is created or selected
123
+ end
124
+ end
125
+ end
126
+
127
+ ### View methods...
128
+
129
+ def lookup_view(target)
130
+ return target if target.is_a?(Scriptorium::View)
131
+
132
+ validate_view_target(target)
133
+
134
+ list = @views.select {|v| v.name == target }
135
+ raise CannotLookupView(target) if list.empty?
136
+ raise MoreThanOneResult(target) if list.size > 1
137
+ return list[0]
138
+ end
139
+
140
+ private def validate_view_target(target)
141
+ raise CannotLookupViewTargetNil if target.nil?
142
+
143
+ raise CannotLookupViewTargetEmpty if target.to_s.strip.empty?
144
+ end
145
+
146
+ def view(change = nil) # get/set current view
147
+ return @current_view if change.nil?
148
+ vnew = change.is_a?(Scriptorium::View) ? change : lookup_view(change)
149
+ write_file(@root/:config/"currentview.txt", vnew.name)
150
+ @current_view = vnew
151
+ @current_view
152
+ end
153
+
154
+ def current_view
155
+ @current_view
156
+ end
157
+
158
+ def view_exist?(name)
159
+ Dir.exist?("#@root/views/#{name}")
160
+ end
161
+
162
+ def create_view(name, title, subtitle = "", theme: "standard")
163
+ assume { name.is_a?(String) }
164
+ assume { title.is_a?(String) }
165
+ validate_view_name(name)
166
+ validate_view_title(title)
167
+
168
+ # Validate name format (only allow alphanumeric, hyphen, underscore)
169
+ unless name.match?(/^[a-zA-Z0-9_-]+$/)
170
+ raise CannotCreateViewNameInvalid(name)
171
+ end
172
+
173
+ raise ViewDirAlreadyExists(name) if view_exist?(name)
174
+ make_tree(@root/:views, <<~EOS)
175
+ #{name}/
176
+ ├── config/ # View-specific config files
177
+ │ ├── layout.txt # Overall layout for front page
178
+ │ ├── footer.txt # Content for footer.html
179
+ │ ├── header.txt # Content for header.html
180
+ │ ├── left.txt # Content for left.html
181
+ │ ├── main.txt # Content for main.html
182
+ │ └── right.txt # Content for right.html
183
+ ├── config.txt # View-specific config file # maybe call settings.txt?
184
+ ├── layout/ # Unused?
185
+ ├── pages/ # Static pages for view
186
+ ├── assets/ # Images, etc. (view-specific)
187
+ │ └── missing/ # Missing assets (SVG placeholder files)
188
+ ├── output/ # Output files (generated HTML)
189
+ │ ├── panes/ # Containers from layout.txt
190
+ │ │ ├── footer.html # Generated from footer.txt
191
+ │ │ ├── header.html # Generated from header.txt
192
+ │ │ ├── left.html # Generated from left.txt
193
+ │ │ ├── main.html # Generated from main.txt
194
+ │ │ └── right.html # Generated from right.txt
195
+ │ └── posts/ # Generated posts for view (slug.html)
196
+ ├── widgets/ # Widgets for view
197
+ └── staging/ # Staging area prior to deployment
198
+ EOS
199
+
200
+ ###
201
+
202
+ dir = "#@root/views/#{name}"
203
+ write_file!(dir/"config.txt",
204
+ "title #{title}",
205
+ "subtitle #{subtitle}",
206
+ "theme #{theme}")
207
+ write_file(dir/:config/"global-head.txt", @predef.html_head_content(true)) # true = view-specific
208
+ write_file(dir/:config/"bootstrap_js.txt", @predef.bootstrap_js)
209
+ write_file(dir/:config/"bootstrap_css.txt", @predef.bootstrap_css)
210
+ write_file(dir/:config/"common.js", @predef.common_js)
211
+ write_file(dir/:config/"social.txt", @predef.social_config)
212
+ write_file(dir/:config/"reddit.txt", @predef.reddit_config)
213
+ write_file(dir/:config/"deploy.txt", @predef.deploy_text % {view: name, domain: "example.com"})
214
+ write_file(dir/:config/"status.txt", @predef.status_txt)
215
+ view = open_view(name)
216
+ @views -= [view]
217
+ @views << view
218
+ @current_view = view
219
+ write_file(@root/:config/"currentview.txt", view.name)
220
+ cfg = dir/:config # Should these be copied from theme??
221
+ theme_config = @root/:themes/theme/:layout/:config
222
+ containers = %w[header.txt footer.txt left.txt right.txt main.txt]
223
+ containers.each { |container| FileUtils.cp(theme_config/container, cfg/container) } # from theme to view
224
+ view.apply_theme(theme)
225
+ verify { view.is_a?(Scriptorium::View) }
226
+ return view
227
+ end
228
+
229
+ def open_view(name)
230
+ vhash = getvars(view_dir(name)/"config.txt")
231
+ title, subtitle, theme = vhash.values_at(:title, :subtitle, :theme)
232
+ view = Scriptorium::View.new(name, title, subtitle, theme)
233
+ @views -= [view]
234
+ @views << view
235
+ # Remove this line - current view should only be set from currentview.txt
236
+ # @current_view = view
237
+ # write_file(@root/:config/"currentview.txt", view.name)
238
+ view
239
+ end
240
+
241
+ def create_draft(title: nil, blurb: nil, views: nil, tags: nil, body: nil)
242
+ ts = Time.now.strftime("%Y%m%d-%H%M%S")
243
+ content_name = "#@root/drafts/#{ts}-draft.lt3"
244
+ metadata_name = "#@root/drafts/#{ts}-draft.meta"
245
+
246
+ # Whoa - what if different views have different themes??? FIXME
247
+ # Maybe solution is as simple as: Initial post is not theme-dependent
248
+ theme = @current_view.theme
249
+ views ||= @current_view.name # initial_post wants a String!
250
+ views, tags = Array(views), Array(tags)
251
+ id = incr_post_num
252
+
253
+ # Create content file (no ID, no created date)
254
+ content = @predef.initial_post_content(title: title, blurb: blurb,
255
+ views: views, tags: tags, body: body)
256
+ write_file(content_name, content)
257
+
258
+ # Create metadata file (with ID and created date)
259
+ metadata = @predef.initial_post_metadata(num: id, title: title, blurb: blurb,
260
+ views: views, tags: tags)
261
+ write_file(metadata_name, metadata)
262
+
263
+ # Return the content file name (for backward compatibility)
264
+ content_name
265
+ end
266
+
267
+ def last_post_num
268
+ read_file(postnum_file).to_i
269
+ end
270
+
271
+ def incr_post_num
272
+ num = last_post_num + 1
273
+ write_file(postnum_file, num.to_s)
274
+ num
275
+ end
276
+
277
+ def finish_draft(name)
278
+ id = last_post_num
279
+ id4 = d4(id)
280
+ posts = @root/:posts
281
+ make_dir(posts/id4)
282
+ make_dir(posts/id4/:assets)
283
+
284
+ # Move content file
285
+ FileUtils.mv(name, posts/id4/"source.lt3")
286
+
287
+ # Move metadata file (same timestamp, different extension)
288
+ metadata_name = name.sub('.lt3', '.meta')
289
+ FileUtils.mv(metadata_name, posts/id4/"meta.txt") if File.exist?(metadata_name)
290
+ id
291
+ end
292
+
293
+ def tree(file = nil)
294
+ cmd = "tree #@root"
295
+ cmd << " >#{file}" if file
296
+ system!(cmd, "generating tree structure")
297
+ end
298
+
299
+
300
+ private def copy_post_assets_to_view(num, view)
301
+ id4 = d4(num)
302
+ post_assets_dir = @root/:posts/id4/"assets"
303
+ view_assets_dir = view.dir/:output/"assets"
304
+
305
+ # Only copy if post has assets
306
+ return unless Dir.exist?(post_assets_dir)
307
+
308
+ # Create view assets directory if it doesn't exist
309
+ make_dir(view_assets_dir)
310
+
311
+ # Copy all files from post assets to view assets
312
+ Dir.glob(post_assets_dir/"*").each do |file|
313
+ next unless File.file?(file)
314
+ filename = File.basename(file)
315
+ target_file = view_assets_dir/filename
316
+
317
+ # Copy file, overwriting if it exists (post assets take precedence)
318
+ FileUtils.cp(file, target_file)
319
+ end
320
+ end
321
+
322
+ private def write_post_metadata(data, view)
323
+ num, title = data.values_at(:"post.id", :"post.title")
324
+ metadata_file = @root/:posts/d4(num)/"meta.txt"
325
+
326
+ # Read existing metadata to preserve fields like post.published
327
+ existing_metadata = {}
328
+ existing_metadata = getvars(metadata_file) if File.exist?(metadata_file)
329
+
330
+ # Prepare new metadata from data
331
+ new_metadata = data.select {|k,v| k.to_s.start_with?("post.") }
332
+ new_metadata.delete(:"post.body")
333
+ new_metadata[:"post.slug"] = slugify(num, title) + ".html"
334
+
335
+ # Merge existing metadata over new metadata to preserve important fields
336
+ # Only preserve fields that should not be overwritten by source file changes
337
+ fields_to_preserve = [:"post.published", :"post.deployed", :"post.created"]
338
+ existing_metadata.each { |key, value| new_metadata[key] = value if fields_to_preserve.include?(key) }
339
+
340
+ lines = new_metadata.map { |k, v| sprintf("%-18s %s", k, v) }
341
+ write_file(metadata_file, lines.join("\n"))
342
+ end
343
+
344
+ private def write_generated_post(data, view, final)
345
+ num, title = data.values_at(:"post.id", :"post.title")
346
+ id4 = d4(num)
347
+ slug = slugify(num, title) + ".html"
348
+ # Write to:
349
+ # root/posts/0123/body.html meta.txt (assets/ draft.lt3)
350
+ top = @root/:posts/id4/"body.html"
351
+ write_file(top, final)
352
+ write_post_metadata(data, view)
353
+ # view/.../output/posts/0123-this-is-me.html
354
+ path = view.dir/:output/:posts/slug
355
+ write_file(path, final)
356
+ # view/.../output/permalink/0123-this-is-me.html (for direct access)
357
+ permalink_path = view.dir/:output/:permalink/slug
358
+ make_dir(File.dirname(permalink_path))
359
+ # Write the permalink version with "Visit Blog" link and "Copy link" button
360
+ permalink_content = final + "\n<div style=\"text-align: center; margin-top: 20px;\">\n<a href=\"../index.html\">Visit Blog</a>\n</div>\n<div style=\"text-align: center; margin-top: 10px;\">\n<button onclick=\"copyPermalinkToClipboard()\" style=\"padding: 8px 16px; background: #007bff; color: white; border: none; border-radius: 4px; cursor: pointer;\">Copy link</button>\n</div>\n<script>\nfunction copyPermalinkToClipboard() {\n navigator.clipboard.writeText(window.location.href).then(function() {\n // Change button text temporarily to show success\n const button = event.target;\n const originalText = button.textContent;\n button.textContent = 'Copied!';\n button.style.background = '#28a745';\n setTimeout(function() {\n button.textContent = originalText;\n button.style.background = '#007bff';\n }, 2000);\n }).catch(function(err) {\n console.error('Failed to copy: ', err);\n alert('Failed to copy link to clipboard');\n });\n}\n</script>"
361
+ write_file(permalink_path, permalink_content)
362
+
363
+ # Create symlink for clean URL (without numeric prefix)
364
+ clean_slug = clean_slugify(title) + ".html"
365
+ clean_symlink_path = view.dir/:output/:permalink/clean_slug
366
+
367
+ # Remove existing symlink if it exists
368
+ File.delete(clean_symlink_path) if File.exist?(clean_symlink_path) && File.symlink?(clean_symlink_path)
369
+
370
+ # Create symlink (relative path from clean_symlink_path to slug)
371
+ begin
372
+ File.symlink(slug, clean_symlink_path)
373
+ rescue Errno::EEXIST => e
374
+ # If symlink already exists (not a symlink), remove it and try again
375
+ File.delete(clean_symlink_path) if File.exist?(clean_symlink_path)
376
+ File.symlink(slug, clean_symlink_path)
377
+ end
378
+
379
+ # Copy post-specific assets to view output directory for deployment
380
+ copy_post_assets_to_view(num, view)
381
+ end
382
+
383
+ def create_post(title: nil, views: nil, tags: nil, body: nil, blurb: nil)
384
+ assume { title.nil? || title.is_a?(String) }
385
+ assume { views.nil? || views.is_a?(Array) || views.is_a?(String) }
386
+ assume { tags.nil? || tags.is_a?(Array) || tags.is_a?(String) }
387
+ assume { body.nil? || body.is_a?(String) }
388
+ assume { blurb.nil? || blurb.is_a?(String) }
389
+ name = create_draft(title: title, views: views, tags: tags, body: body, blurb: blurb)
390
+ num = finish_draft(name)
391
+ generate_post(num)
392
+ post = self.post(num) # Return the Post object
393
+ verify { post.is_a?(Scriptorium::Post) }
394
+ post
395
+ end
396
+
397
+ def publish_post(num)
398
+ validate_post_id(num)
399
+ metadata_file = @root/:posts/d4(num)/"meta.txt"
400
+
401
+ # Read current metadata if it exists
402
+ metadata = {}
403
+ metadata = getvars(metadata_file) if File.exist?(metadata_file)
404
+
405
+ # Check if already published
406
+ if metadata[:"post.published"] != "no" && metadata[:"post.published"] != nil
407
+ raise "Post #{num} is already published"
408
+ end
409
+
410
+ # Update published timestamp
411
+ metadata[:"post.published"] = ymdhms
412
+
413
+ # Write updated metadata
414
+ lines = metadata.map { |k, v| sprintf("%-18s %s", k, v) }
415
+ write_file(metadata_file, lines.join("\n"))
416
+
417
+ # Generate the post (this will preserve the updated metadata)
418
+ generate_post(num)
419
+
420
+ self.post(num)
421
+ end
422
+
423
+ def post_published?(num)
424
+ validate_post_id(num)
425
+ metadata_file = @root/:posts/d4(num)/"meta.txt"
426
+ return false unless File.exist?(metadata_file)
427
+
428
+ metadata = getvars(metadata_file)
429
+ result = metadata[:"post.published"] != "no"
430
+ result
431
+ end
432
+
433
+ def get_published_posts(view = nil)
434
+ all_posts = all_posts(view)
435
+ all_posts.select { |post| post_published?(post.id) }
436
+ end
437
+
438
+ def generate_post(num)
439
+ content_file = @root/:posts/d4(num)/"source.lt3"
440
+ metadata_file = @root/:posts/d4(num)/"meta.txt"
441
+
442
+ need(:file, content_file)
443
+
444
+ # Read content file
445
+ vars = { View: @current_view.name, :"post.id" => num }
446
+ # live = Livetext.customize(mix: "lt3scriptor", call: ".nopara", vars: vars)
447
+ # text = live.xform_file(content_file)
448
+ # vars, _body = live.vars.vars, live.body
449
+
450
+ live = Livetext.customize(mix: "lt3scriptor", call: ".nopara", vars: vars)
451
+ body, vars = live.process(file: content_file)
452
+
453
+ # Create or update metadata from post content
454
+ if File.exist?(metadata_file)
455
+ # Preserve existing metadata (like post.published timestamp)
456
+ existing_metadata = getvars(metadata_file)
457
+ metadata_vars = create_metadata_from_content(num, vars)
458
+ # Merge existing metadata over defaults
459
+ existing_metadata.each do |key, value|
460
+ metadata_vars[key] = value
461
+ end
462
+ else
463
+ # Create new metadata
464
+ metadata_vars = create_metadata_from_content(num, vars)
465
+ end
466
+
467
+ # Write metadata file
468
+ lines = metadata_vars.map { |k, v| sprintf("%-18s %s", k, v) }
469
+ write_file(metadata_file, lines.join("\n"))
470
+
471
+ # Merge metadata into vars, but don't override content vars
472
+ metadata_vars.each { |key, value| vars[key] = value unless vars.key?(key) }
473
+
474
+ views = vars[:"post.views"].strip.split(/\s+/)
475
+ vars[:"post.views"] = views.join(" ") # Ensure post.views is set in vars
476
+ views.each do |view|
477
+ view = lookup_view(view)
478
+ theme = view.theme
479
+ vars[:"post.id"] = num.to_s # Always use the post number as ID
480
+ vars[:"post.body"] = body
481
+ template = @predef.post_template("standard")
482
+ set_pubdate(vars)
483
+ # Add Reddit button if enabled
484
+ vars[:"reddit_button"] = view.generate_reddit_button(vars)
485
+ final = substitute(vars, template)
486
+ write_generated_post(vars, view, final)
487
+ end
488
+ end
489
+
490
+ private def create_metadata_from_content(num, vars)
491
+ metadata = {}
492
+
493
+ # Set required fields
494
+ metadata[:"post.id"] = d4(num)
495
+ metadata[:"post.created"] = ymdhms
496
+ metadata[:"post.published"] = "no" # Default to unpublished
497
+ metadata[:"post.deployed"] = "no"
498
+
499
+ # Copy fields from content vars
500
+ metadata[:"post.title"] = vars[:"post.title"] || "ADD TITLE HERE"
501
+ metadata[:"post.blurb"] = vars[:"post.blurb"] || "ADD BLURB HERE"
502
+ metadata[:"post.views"] = vars[:"post.views"] || "sample"
503
+ metadata[:"post.tags"] = vars[:"post.tags"] || ""
504
+
505
+ metadata
506
+ end
507
+
508
+ private def set_pubdate(vars) # Not Post#set_pubdate
509
+ t = Time.now
510
+ vars[:"post.pubdate"] = t.strftime("%Y-%m-%d %H:%M:%S")
511
+ vars[:"post.pubdate.month"] = t.strftime("%B")
512
+ vars[:"post.pubdate.day"] = t.strftime("%d")
513
+ vars[:"post.pubdate.year"] = t.strftime("%Y")
514
+ end
515
+
516
+ def all_posts(view = nil)
517
+ posts = []
518
+ dirs = Dir.children(@root/:posts)
519
+ dirs.each do |id4|
520
+ # Skip deleted posts (directories starting with underscore)
521
+ next if id4.start_with?('_')
522
+ posts << Scriptorium::Post.read(self, id4)
523
+ end
524
+ return posts if view.nil?
525
+ view = lookup_view(view)
526
+ posts.select {|x| x.views.include?(view.name) }
527
+ end
528
+
529
+ def generate_post_index(view)
530
+ view = lookup_view(view)
531
+ view.generate_post_index
532
+ end
533
+
534
+ def post(id)
535
+ validate_post_id(id)
536
+
537
+ # Check normal directory first
538
+ meta = @root/:posts/d4(id)/"meta.txt"
539
+ return Scriptorium::Post.new(self, id) if File.exist?(meta)
540
+
541
+ # Check deleted directory (with underscore prefix)
542
+ deleted_meta = @root/:posts/"_#{d4(id)}"/"meta.txt"
543
+ return Scriptorium::Post.new(self, id) if File.exist?(deleted_meta)
544
+
545
+ # Post not found in either location
546
+ nil
547
+ end
548
+
549
+ private def validate_post_id(id)
550
+ raise CannotGetPostIdNil if id.nil?
551
+
552
+ raise CannotGetPostIdEmpty if id.to_s.strip.empty?
553
+
554
+ unless id.to_s.match?(/^\d+$/)
555
+ raise CannotGetPostIdInvalid(id)
556
+ end
557
+ end
558
+
559
+ def generate_front_page(view)
560
+ view = lookup_view(view)
561
+ view.generate_front_page
562
+ end
563
+
564
+ # Reddit integration
565
+ def reddit
566
+ @reddit ||= Scriptorium::Reddit.new(self)
567
+ end
568
+
569
+ def autopost_to_reddit(post_data, subreddit = nil)
570
+ reddit.autopost(post_data, subreddit)
571
+ end
572
+
573
+ def reddit_configured?
574
+ reddit.configured?
575
+ end
576
+
577
+ private def validate_view_name(name)
578
+ raise CannotCreateViewNameNil if name.nil?
579
+
580
+ raise CannotCreateViewNameEmpty if name.to_s.strip.empty?
581
+ end
582
+
583
+ private def validate_view_title(title)
584
+ raise CannotCreateViewTitleNil if title.nil?
585
+
586
+ raise CannotCreateViewTitleEmpty if title.to_s.strip.empty?
587
+ end
588
+
589
+ def self.generate_os_helpers(root)
590
+ os_code = case RbConfig::CONFIG['host_os']
591
+ when /darwin/ # macOS
592
+ <<~RUBY
593
+ # Generated at repo creation for macOS
594
+ def open_file(file_path)
595
+ system("open", file_path)
596
+ end
597
+ RUBY
598
+ when /linux/ # Linux
599
+ <<~RUBY
600
+ # Generated at repo creation for Linux
601
+ def open_file(file_path)
602
+ system("xdg-open", file_path)
603
+ end
604
+ RUBY
605
+ when /mswin|mingw|cygwin/ # Windows
606
+ <<~RUBY
607
+ # Generated at repo creation for Windows
608
+ def open_file(file_path)
609
+ system("start", file_path)
610
+ end
611
+ RUBY
612
+ else
613
+ <<~RUBY
614
+ # Generated at repo creation for unknown OS
615
+ def open_file(file_path)
616
+ puts " Unable to open file on this OS"
617
+ end
618
+ RUBY
619
+ end
620
+
621
+ write_file(root/:config/"os_helpers.rb", os_code)
622
+ end
623
+
624
+ end