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.
- checksums.yaml +4 -4
- data/README.lt3 +324 -0
- data/README.md +3155 -1
- data/assets/.DS_Store +0 -0
- data/assets/README.md +44 -0
- data/assets/back-icon.png +0 -0
- data/assets/icons/facebook.svg +1 -0
- data/assets/icons/github.svg +1 -0
- data/assets/icons/instagram.svg +1 -0
- data/assets/icons/reddit.svg +1 -0
- data/assets/icons/ui/.DS_Store +0 -0
- data/assets/icons/ui/back.png +0 -0
- data/assets/icons/ui/copy.png +0 -0
- data/assets/icons/ui/down.png +0 -0
- data/assets/icons/ui/end.png +0 -0
- data/assets/icons/ui/exit.png +0 -0
- data/assets/icons/ui/foo +10 -0
- data/assets/icons/ui/home.png +0 -0
- data/assets/icons/ui/left.png +0 -0
- data/assets/icons/ui/next.png +0 -0
- data/assets/icons/ui/right.png +0 -0
- data/assets/icons/ui/start.png +0 -0
- data/assets/icons/ui/up.png +0 -0
- data/assets/icons/x.svg +1 -0
- data/assets/icons/youtube.svg +1 -0
- data/assets/samples/placeholder.svg +9 -0
- data/assets/themes/standard/favicon.svg +6 -0
- data/bin/scriptorium +1511 -0
- data/doc/README.txt +6 -0
- data/doc/anti-amnesia/20250727-054000-scriptorium-overview.md +95 -0
- data/doc/anti-amnesia/20250727-060000-api-design-tui-planning.md +34 -0
- data/doc/anti-amnesia/20250727-061000-runeblog-tui-analysis.md +50 -0
- data/doc/anti-amnesia/20250727-123000-anti-amnesia-conventions.md +31 -0
- data/doc/anti-amnesia/20250727-154000-livetext-plugin-file-stats.md +73 -0
- data/doc/anti-amnesia/20250727-172600-cursor-rbenv-ruby-version-mystery.md +64 -0
- data/doc/anti-amnesia/20250727-172600-unified-minitest-framework.md +70 -0
- data/doc/anti-amnesia/20250727-172900-ai-cognitive-assessment-capabilities.md +40 -0
- data/doc/anti-amnesia/20250727-173000-widget-testing-achievement.md +110 -0
- data/doc/anti-amnesia/20250727-180000-post-id-num-refactoring.md +73 -0
- data/doc/anti-amnesia/20250728-124243-aaa-syntax-clarification.md +46 -0
- data/doc/anti-amnesia/20250728-124421-conversation-summary-concise.md +124 -0
- data/doc/anti-amnesia/20250729-190000-scriptorium-tui-testing-complete.md +46 -0
- data/doc/anti-amnesia/20250729-200000-scriptorium-tui-testing-edit-file-workflow.md +97 -0
- data/doc/anti-amnesia/20250729-210000-reddit-autopost-integration-complete.md +158 -0
- data/doc/anti-amnesia/20250729-211500-dependency-management-system.md +211 -0
- data/doc/anti-amnesia/20250729-213000-python-virtual-environment-setup.md +141 -0
- data/doc/anti-amnesia/20250729-214500-theme-management-commands.md +211 -0
- data/doc/anti-amnesia/20250729-215000-version-update-to-0.6.0.md +134 -0
- data/doc/anti-amnesia/20250729-220000-user-guide-complete.md +41 -0
- data/doc/anti-amnesia/20250804-190500-cognitive-loop-bug.md +45 -0
- data/doc/anti-amnesia/20250804-190700-anti-amnesia-timestamping-fix.md +30 -0
- data/doc/anti-amnesia/20250804-213700-publishing-test-fix.md +49 -0
- data/doc/anti-amnesia/20250804-214400-additional-test-fixes.md +46 -0
- data/doc/anti-amnesia/20250804-220000-asset-function-logic-clarification.md +41 -0
- data/doc/anti-amnesia/20250806-202032-asset-function-logic-clarification.md +41 -0
- data/doc/anti-amnesia/20250807-213025.md +116 -0
- data/doc/anti-amnesia/20250813-082428-syntax-highlighting-and-navigation-improvements.md +256 -0
- data/doc/banner_svg_config.md +114 -0
- data/doc/contrib.lt3 +8 -0
- data/doc/dependencies.md +281 -0
- data/doc/hacker.lt3 +5 -0
- data/doc/reddit_credentials_template.json +8 -0
- data/doc/reddit_integration.md +207 -0
- data/doc/user.lt3 +38 -0
- data/doc/user_guide_section_1.md +137 -0
- data/doc/user_guide_section_10.md +515 -0
- data/doc/user_guide_section_11.md +708 -0
- data/doc/user_guide_section_2.md +233 -0
- data/doc/user_guide_section_3.md +5 -0
- data/doc/user_guide_section_4.md +221 -0
- data/doc/user_guide_section_5.md +243 -0
- data/doc/user_guide_section_6.md +147 -0
- data/doc/user_guide_section_7.md +311 -0
- data/doc/user_guide_section_8.md +224 -0
- data/doc/user_guide_section_9.md +375 -0
- data/doc/userdoc-toc.txt +88 -0
- data/lib/rouge/lexers/livetext.rb +74 -0
- data/lib/scriptorium/api.rb +640 -0
- data/lib/scriptorium/banner_svg.rb +742 -0
- data/lib/scriptorium/contract.rb +33 -0
- data/lib/scriptorium/exceptions.rb +174 -0
- data/lib/scriptorium/helpers.rb +475 -0
- data/lib/scriptorium/post.rb +195 -0
- data/lib/scriptorium/reddit.rb +83 -0
- data/lib/scriptorium/repo.rb +624 -0
- data/lib/scriptorium/standard_files.rb +515 -0
- data/lib/scriptorium/syntax_highlighter.rb +234 -0
- data/lib/scriptorium/theme.rb +179 -0
- data/lib/scriptorium/version.rb +2 -2
- data/lib/scriptorium/view.rb +976 -0
- data/lib/scriptorium/widgets/featured_posts.rb +149 -0
- data/lib/scriptorium/widgets/links.rb +112 -0
- data/lib/scriptorium/widgets/pages.rb +133 -0
- data/lib/scriptorium/widgets/widget.rb +133 -0
- data/lib/scriptorium.rb +22 -9
- data/lib/skeleton.rb +11 -2
- data/scriptorium.gemspec +15 -4
- data/test/README.md +69 -0
- data/test/all +43 -0
- data/test/api_demo.rb +99 -0
- data/test/assets/imagenotfound.jpg +0 -0
- data/test/assets/images/.DS_Store +0 -0
- data/test/assets/images/README.md +27 -0
- data/test/assets/images/odd_aspect.png +0 -0
- data/test/assets/images/perfect.png +0 -0
- data/test/assets/images/small.png +0 -0
- data/test/assets/images/tall.png +0 -0
- data/test/assets/images/very_tall.png +0 -0
- data/test/assets/images/very_wide.png +0 -0
- data/test/assets/images/wide.png +0 -0
- data/test/assets/testbanner.jpg +0 -0
- data/test/banner_svg/simple_helpers.rb +13 -0
- data/test/banner_svg/unit.rb +768 -0
- data/test/ed_test.rb +204 -0
- data/test/integration/cursor_banner_combinations.rb +193 -0
- data/test/integration/cursor_banner_features.rb +374 -0
- data/test/integration/integration_test.rb +326 -0
- data/test/livetext_plugin_test.rb +229 -0
- data/test/manual/asset_mgmt.rb +67 -0
- data/test/manual/banner-tests/config.txt +3 -0
- data/test/manual/banner-tests/index.html +45 -0
- data/test/manual/banner-tests/test01.html +58 -0
- data/test/manual/banner-tests/test02.html +58 -0
- data/test/manual/banner-tests/test03.html +58 -0
- data/test/manual/banner-tests/test04.html +65 -0
- data/test/manual/banner-tests/test05.html +65 -0
- data/test/manual/banner-tests/test06.html +65 -0
- data/test/manual/banner-tests/test07.html +65 -0
- data/test/manual/banner-tests/test08.html +59 -0
- data/test/manual/banner-tests/test09.html +59 -0
- data/test/manual/banner-tests/test10.html +59 -0
- data/test/manual/banner-tests/test11.html +59 -0
- data/test/manual/banner-tests/test12.html +59 -0
- data/test/manual/banner-tests/test13.html +59 -0
- data/test/manual/banner-tests/test14.html +59 -0
- data/test/manual/banner-tests/test15.html +58 -0
- data/test/manual/banner-tests/test16.html +58 -0
- data/test/manual/banner-tests/test17.html +58 -0
- data/test/manual/banner-tests/test18.html +68 -0
- data/test/manual/banner-tests/test19.html +68 -0
- data/test/manual/banner-tests/test20.html +68 -0
- data/test/manual/banner-tests/test21.html +68 -0
- data/test/manual/banner-tests/test22.html +68 -0
- data/test/manual/banner-tests/test23.html +68 -0
- data/test/manual/banner-tests/test24.html +68 -0
- data/test/manual/banner-tests/test25.html +67 -0
- data/test/manual/banner_environment.rb +192 -0
- data/test/manual/deploy_symlink_demo.rb +142 -0
- data/test/manual/environment.rb +67 -0
- data/test/manual/make_banner.rb +153 -0
- data/test/manual/sample_banner_config.txt +12 -0
- data/test/manual/symlink_demo.rb +117 -0
- data/test/manual/test1.rb +47 -0
- data/test/manual/test2.rb +12 -0
- data/test/manual/test3.rb +38 -0
- data/test/manual/test4.rb +40 -0
- data/test/manual/test5.rb +24 -0
- data/test/manual/test6.rb +73 -0
- data/test/manual/test_banner_combinations.rb +120 -0
- data/test/manual/test_banner_features.rb +306 -0
- data/test/manual/test_banner_from_file.rb +150 -0
- data/test/manual/test_banner_in_header.rb +35 -0
- data/test/manual/test_code_highlighting.rb +68 -0
- data/test/manual/test_complex_header.rb +74 -0
- data/test/manual/test_empty_header.rb +32 -0
- data/test/manual/test_radial_custom.rb +58 -0
- data/test/manual/test_radial_large_radius.rb +52 -0
- data/test/manual/test_svg_debug.rb +47 -0
- data/test/manual/test_syntax_highlighting.rb +147 -0
- data/test/pages-demo/config/currentview.txt +1 -0
- data/test/pages-demo/views/demo/config/bootstrap_css.txt +5 -0
- data/test/pages-demo/views/demo/config/bootstrap_js.txt +4 -0
- data/test/pages-demo/views/demo/config/common.js +57 -0
- data/test/pages-demo/views/demo/config/footer.txt +1 -0
- data/test/pages-demo/views/demo/config/global-head.txt +8 -0
- data/test/pages-demo/views/demo/config/header.txt +1 -0
- data/test/pages-demo/views/demo/config/layout.txt +1 -0
- data/test/pages-demo/views/demo/config/left.txt +1 -0
- data/test/pages-demo/views/demo/config/main.txt +1 -0
- data/test/pages-demo/views/demo/config/right.txt +1 -0
- data/test/pages-demo/views/demo/config.txt +3 -0
- data/test/pages-demo/views/demo/output/panes/footer.html +1 -0
- data/test/pages-demo/views/demo/output/panes/header.html +1 -0
- data/test/pages-demo/views/demo/output/panes/left.html +1 -0
- data/test/pages-demo/views/demo/output/panes/main.html +1 -0
- data/test/pages-demo/views/demo/output/panes/right.html +1 -0
- data/test/rubytext/rubytext_comprehensive_test.rb +307 -0
- data/test/rubytext/rubytext_demo_test.rb +42 -0
- data/test/rubytext/rubytext_testing_guide.md +277 -0
- data/test/run_automated_tests.rb +45 -0
- data/test/scriptorium-TEST-1754622690-146/config/bootstrap_css.txt +5 -0
- data/test/scriptorium-TEST-1754622690-146/config/bootstrap_js.txt +4 -0
- data/test/scriptorium-TEST-1754622690-146/config/common.js +57 -0
- data/test/scriptorium-TEST-1754622690-146/config/currentview.txt +1 -0
- data/test/scriptorium-TEST-1754622690-146/config/global-head.txt +9 -0
- data/test/scriptorium-TEST-1754622690-146/config/last_post_num.txt +1 -0
- data/test/scriptorium-TEST-1754622690-146/config/os_helpers.rb +4 -0
- data/test/scriptorium-TEST-1754622690-146/config/widgets.txt +3 -0
- data/test/scriptorium-TEST-1754622690-146/posts/0001/meta.txt +8 -0
- data/test/scriptorium-TEST-1754622690-146/posts/0001/source.lt3 +6 -0
- data/test/scriptorium-TEST-1754622690-146/themes/standard/README.txt +1 -0
- data/test/scriptorium-TEST-1754622690-146/themes/standard/config.txt +1 -0
- data/test/scriptorium-TEST-1754622690-146/themes/standard/initial/post.lt3 +12 -0
- data/test/scriptorium-TEST-1754622690-146/themes/standard/layout/config/footer.txt +2 -0
- data/test/scriptorium-TEST-1754622690-146/themes/standard/layout/config/header.txt +4 -0
- data/test/scriptorium-TEST-1754622690-146/themes/standard/layout/config/left.txt +3 -0
- data/test/scriptorium-TEST-1754622690-146/themes/standard/layout/config/main.txt +5 -0
- data/test/scriptorium-TEST-1754622690-146/themes/standard/layout/config/right.txt +3 -0
- data/test/scriptorium-TEST-1754622690-146/themes/standard/layout/gen/text.css +1 -0
- data/test/scriptorium-TEST-1754622690-146/themes/standard/layout/layout.txt +5 -0
- data/test/scriptorium-TEST-1754622690-146/themes/standard/templates/index.lt3 +1 -0
- data/test/scriptorium-TEST-1754622690-146/themes/standard/templates/index_entry.lt3 +14 -0
- data/test/scriptorium-TEST-1754622690-146/themes/standard/templates/post.lt3 +13 -0
- data/test/scriptorium-TEST-1754622690-146/themes/standard/templates/widget.lt3 +1 -0
- data/test/scriptorium-TEST-1754622690-146/views/sample/config/bootstrap_css.txt +5 -0
- data/test/scriptorium-TEST-1754622690-146/views/sample/config/bootstrap_js.txt +4 -0
- data/test/scriptorium-TEST-1754622690-146/views/sample/config/common.js +57 -0
- data/test/scriptorium-TEST-1754622690-146/views/sample/config/deploy.txt +5 -0
- data/test/scriptorium-TEST-1754622690-146/views/sample/config/footer.txt +2 -0
- data/test/scriptorium-TEST-1754622690-146/views/sample/config/global-head.txt +9 -0
- data/test/scriptorium-TEST-1754622690-146/views/sample/config/header.txt +4 -0
- data/test/scriptorium-TEST-1754622690-146/views/sample/config/layout.txt +5 -0
- data/test/scriptorium-TEST-1754622690-146/views/sample/config/left.txt +3 -0
- data/test/scriptorium-TEST-1754622690-146/views/sample/config/main.txt +5 -0
- data/test/scriptorium-TEST-1754622690-146/views/sample/config/reddit.txt +10 -0
- data/test/scriptorium-TEST-1754622690-146/views/sample/config/right.txt +3 -0
- data/test/scriptorium-TEST-1754622690-146/views/sample/config/social.txt +7 -0
- data/test/scriptorium-TEST-1754622690-146/views/sample/config/status.txt +7 -0
- data/test/scriptorium-TEST-1754622690-146/views/sample/config.txt +3 -0
- data/test/scriptorium-TEST-1754622690-146/views/sample/layout/footer.html +3 -0
- data/test/scriptorium-TEST-1754622690-146/views/sample/layout/header.html +3 -0
- data/test/scriptorium-TEST-1754622690-146/views/sample/layout/left.html +3 -0
- data/test/scriptorium-TEST-1754622690-146/views/sample/layout/main.html +3 -0
- data/test/scriptorium-TEST-1754622690-146/views/sample/layout/right.html +3 -0
- data/test/scriptorium-TEST-1754622690-146/views/sample/output/panes/footer.html +1 -0
- data/test/scriptorium-TEST-1754622690-146/views/sample/output/panes/header.html +1 -0
- data/test/scriptorium-TEST-1754622690-146/views/sample/output/panes/left.html +1 -0
- data/test/scriptorium-TEST-1754622690-146/views/sample/output/panes/main.html +1 -0
- data/test/scriptorium-TEST-1754622690-146/views/sample/output/panes/right.html +1 -0
- data/test/staging/.DS_Store +0 -0
- data/test/syntax_highlighting_test.lt3 +124 -0
- data/test/test_helpers.rb +230 -0
- data/test/tui_editor_integration_test.rb +296 -0
- data/test/tui_integration_test.rb +637 -0
- data/test/unit/api.rb +1056 -0
- data/test/unit/asset_management.rb +245 -0
- data/test/unit/clipboard_test.rb +60 -0
- data/test/unit/contract_test.rb +91 -0
- data/test/unit/core.rb +857 -0
- data/test/unit/deploy_test.rb +187 -0
- data/test/unit/gem_asset_management.rb +189 -0
- data/test/unit/livetext_basic.rb +69 -0
- data/test/unit/livetext_compatibility.rb +89 -0
- data/test/unit/post.rb +244 -0
- data/test/unit/read_commented_file_test.rb +276 -0
- data/test/unit/reddit_test.rb +235 -0
- data/test/unit/repo.rb +548 -0
- data/test/unit/social_test.rb +369 -0
- data/test/unit/symlink_test.rb +213 -0
- data/test/unit/view.rb +431 -0
- data/test/unit/widgets.rb +669 -0
- data/test/wizard_test.rb +123 -0
- data/ui/README.md +67 -0
- data/ui/common/lib/ui_common.rb +8 -0
- data/ui/rubytext/README.md +191 -0
- data/ui/rubytext/bin/scriptorium-rubytext +402 -0
- data/ui/rubytext/lib/rubytext_ui.rb +300 -0
- data/ui/tui/bin/scriptorium +1420 -0
- data/ui/tui/test/tui_test.rb +23 -0
- data/ui/web/app/app.rb +1378 -0
- data/ui/web/app/error_helpers.rb +150 -0
- data/ui/web/app/views/advanced_config.erb +190 -0
- data/ui/web/app/views/asset_management.erb +589 -0
- data/ui/web/app/views/banner_config.erb +200 -0
- data/ui/web/app/views/configure_view.erb +401 -0
- data/ui/web/app/views/dashboard.erb +162 -0
- data/ui/web/app/views/deploy_config.erb +146 -0
- data/ui/web/app/views/edit_pages.erb +195 -0
- data/ui/web/app/views/edit_post.erb +54 -0
- data/ui/web/app/views/error_page.erb +29 -0
- data/ui/web/app/views/header_config.erb +155 -0
- data/ui/web/app/views/layout_config.erb +147 -0
- data/ui/web/app/views/navbar_config.erb +411 -0
- data/ui/web/app/views/view_dashboard.erb +138 -0
- data/ui/web/bin/scriptorium-web +153 -0
- data/ui/web/test/web_basic_test.rb +38 -0
- data/ui/web/test_navbar.txt +7 -0
- data/ui/web/tmp/web_server.log +5 -0
- data/ui/web/tmp/web_server.pid +1 -0
- 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> "
|
877
|
+
else
|
878
|
+
str << %[<a href="javascript:void(0)" style="text-decoration: none;"
|
879
|
+
onclick="load_main('page#{i}.html')">#{i} </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
|