ruby-md-ssg 0.1.0

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.
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+ require 'yaml'
5
+ require_relative 'paths'
6
+ require_relative 'document_finder'
7
+ require_relative 'menu_config'
8
+
9
+ module RubyMdSsg
10
+ class MenuGenerator
11
+ def initialize(docs_dir: Paths.docs_dir, menu_path: Paths.menu_config)
12
+ @docs_dir = docs_dir
13
+ @menu_path = menu_path
14
+ end
15
+
16
+ def generate
17
+ documents = DocumentFinder.new(docs_dir: docs_dir).all
18
+ default_menu = MenuConfig.from_documents(documents)
19
+ config = if File.exist?(menu_path)
20
+ merge_existing(MenuConfig.load(menu_path), default_menu)
21
+ else
22
+ default_menu
23
+ end
24
+
25
+ serialized = YAML.dump(config.to_h)
26
+ FileUtils.mkdir_p(File.dirname(menu_path))
27
+ existing = File.exist?(menu_path) ? File.read(menu_path) : nil
28
+ File.write(menu_path, serialized) unless existing == serialized
29
+ menu_path
30
+ end
31
+
32
+ private
33
+
34
+ attr_reader :docs_dir, :menu_path
35
+
36
+ def merge_existing(existing, default_menu)
37
+ default_lookup = {}
38
+ default_menu.sections.each do |section|
39
+ default_lookup[normalized(section.title)] = section
40
+ end
41
+
42
+ merged_sections = existing.sections.map do |existing_section|
43
+ key = normalized(existing_section.title)
44
+ default_section = default_lookup.delete(key)
45
+ if default_section
46
+ SectionMerger.merge(existing_section, default_section)
47
+ else
48
+ existing_section
49
+ end
50
+ end
51
+
52
+ additional_sections = default_lookup.values.sort_by(&:title)
53
+ MenuConfig.new(merged_sections + additional_sections)
54
+ end
55
+
56
+ def normalized(value)
57
+ value.to_s.downcase
58
+ end
59
+
60
+ module SectionMerger
61
+ module_function
62
+
63
+ def merge(existing_section, default_section)
64
+ existing_links = {}
65
+ existing_section.links.each do |link|
66
+ existing_links[normalized(link.route)] = link
67
+ end
68
+ default_section.links.each do |link|
69
+ key = normalized(link.route)
70
+ existing_links[key] ||= link
71
+ end
72
+
73
+ merged_links = existing_section.links.map { |link| existing_links[normalized(link.route)] }
74
+ additional_links = existing_links.values.reject { |link| merged_links.include?(link) }
75
+ merged_links += additional_links
76
+ RubyMdSsg::MenuConfig::Section.new(
77
+ title: existing_section.title,
78
+ links: merged_links
79
+ )
80
+ end
81
+
82
+ def normalized(value)
83
+ value.to_s.downcase
84
+ end
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyMdSsg
4
+ module Paths
5
+ module_function
6
+
7
+ def root
8
+ @root ||= begin
9
+ env_root = ENV.fetch('RUBY_MD_SSG_ROOT', nil)
10
+ if env_root && !env_root.empty?
11
+ File.expand_path(env_root)
12
+ else
13
+ Dir.pwd
14
+ end
15
+ end
16
+ end
17
+
18
+ def root=(value)
19
+ @root = value && File.expand_path(value)
20
+ end
21
+
22
+ def reset!
23
+ @root = nil
24
+ end
25
+
26
+ def docs_dir
27
+ File.join(root, 'docs')
28
+ end
29
+
30
+ def build_dir
31
+ File.join(root, 'build')
32
+ end
33
+
34
+ def assets_dir
35
+ File.join(root, 'assets')
36
+ end
37
+
38
+ def menu_config
39
+ File.join(docs_dir, 'menu.yml')
40
+ end
41
+
42
+ def templates_dir
43
+ File.join(root, 'templates')
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,120 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'webrick'
4
+ require 'digest'
5
+
6
+ module RubyMdSsg
7
+ class Server
8
+ class << self
9
+ def start(options = {})
10
+ new(default_options.merge(options)).start
11
+ end
12
+
13
+ private
14
+
15
+ def default_options
16
+ {
17
+ port: 4000,
18
+ docs: Paths.docs_dir,
19
+ build: Paths.build_dir,
20
+ assets: Paths.assets_dir,
21
+ menu: Paths.menu_config,
22
+ auto_build: true,
23
+ watch: true,
24
+ interval: 1.0,
25
+ base_url: ENV.fetch('RUBY_MD_SSG_BASE_URL', nil)
26
+ }
27
+ end
28
+ end
29
+
30
+ def initialize(options)
31
+ @options = options
32
+ @server = nil
33
+ end
34
+
35
+ def start
36
+ build_site if options[:auto_build]
37
+ watch_for_changes if options[:watch]
38
+ start_server
39
+ end
40
+
41
+ private
42
+
43
+ attr_reader :options, :server
44
+
45
+ def build_site
46
+ generator = MenuGenerator.new(docs_dir: options[:docs], menu_path: options[:menu])
47
+ generator.generate
48
+
49
+ compiler = Compiler.new(
50
+ docs_dir: options[:docs],
51
+ build_dir: options[:build],
52
+ assets_dir: options[:assets],
53
+ menu_path: options[:menu],
54
+ base_url: options[:base_url]
55
+ )
56
+ compiler.compile
57
+ puts "[#{timestamp}] Build complete."
58
+ rescue StandardError => e
59
+ warn "[build] #{e.class}: #{e.message}"
60
+ e.backtrace&.take(5)&.each { |line| warn " #{line}" }
61
+ end
62
+
63
+ def watch_for_changes
64
+ Thread.new do
65
+ last = fingerprint
66
+ loop do
67
+ sleep options[:interval]
68
+ current = fingerprint
69
+ next if current == last
70
+
71
+ puts '[watch] Change detected. Rebuilding...'
72
+ build_site
73
+ last = current
74
+ rescue StandardError => e
75
+ warn "[watch] #{e.class}: #{e.message}"
76
+ end
77
+ end
78
+ end
79
+
80
+ def start_server
81
+ @server = WEBrick::HTTPServer.new(
82
+ Port: options[:port],
83
+ DocumentRoot: options[:build],
84
+ AccessLog: [],
85
+ Logger: WEBrick::Log.new($stderr, WEBrick::Log::INFO)
86
+ )
87
+
88
+ trap('INT') { server.shutdown }
89
+
90
+ puts "Serving #{options[:build]} at http://localhost:#{options[:port]}"
91
+ server.start
92
+ end
93
+
94
+ def fingerprint
95
+ files = watched_files
96
+ return 'empty' if files.empty?
97
+
98
+ Digest::SHA1.hexdigest(
99
+ files.sort.flat_map do |path|
100
+ [path, File.mtime(path).to_f.to_s, (File.size?(path) || 0).to_s]
101
+ end.join('|')
102
+ )
103
+ end
104
+
105
+ def watched_files
106
+ patterns = [
107
+ File.join(options[:docs], '**', '*.md'),
108
+ File.join(options[:docs], '*.yml'),
109
+ File.join(options[:assets], '**', '*'),
110
+ File.join(Paths.root, 'templates', '**', '*.erb')
111
+ ]
112
+ patterns.flat_map { |pattern| Dir.glob(pattern, File::FNM_DOTMATCH) }
113
+ .select { |path| File.file?(path) }
114
+ end
115
+
116
+ def timestamp
117
+ Time.now.strftime('%H:%M:%S')
118
+ end
119
+ end
120
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyMdSsg
4
+ VERSION = '0.1.0'
5
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'ruby_md_ssg/version'
4
+ require_relative 'ruby_md_ssg/paths'
5
+ require_relative 'ruby_md_ssg/document'
6
+ require_relative 'ruby_md_ssg/document_finder'
7
+ require_relative 'ruby_md_ssg/menu_config'
8
+ require_relative 'ruby_md_ssg/menu_generator'
9
+ require_relative 'ruby_md_ssg/markdown_renderer'
10
+ require_relative 'ruby_md_ssg/layout_renderer'
11
+ require_relative 'ruby_md_ssg/html_formatter'
12
+ require_relative 'ruby_md_ssg/compiler'
13
+ require_relative 'ruby_md_ssg/server'
14
+ require_relative 'ruby_md_ssg/cli'
@@ -0,0 +1,12 @@
1
+ source 'https://rubygems.org'
2
+
3
+ gem 'ruby-md-ssg', '~> <%= gem_version %>'
4
+
5
+ group :development do
6
+ gem 'rake', '~> 13.0'
7
+ gem 'rubocop', require: false
8
+ end
9
+
10
+ group :test do
11
+ gem 'minitest', '~> 5.22'
12
+ end
@@ -0,0 +1,16 @@
1
+ # <%= project_name %>
2
+
3
+ Generated by Ruby MD SSG <%= gem_version %>.
4
+
5
+ ## Commands
6
+
7
+ - `bundle exec ruby_md_ssg build` — regenerate the site into `build/` (writes `sitemap.xml`)
8
+ - `bundle exec ruby_md_ssg serve` — serve `build/` with automatic rebuilds
9
+ - `bundle exec ruby_md_ssg menu` — refresh `docs/menu.yml`
10
+ - `bundle exec ruby_md_ssg test` — run the Minitest suite (if present)
11
+
12
+ Set `RUBY_MD_SSG_BASE_URL` (or pass `--base-url`) when building to control sitemap URLs.
13
+
14
+ Docs live in `docs/`, assets in `assets/`, templates in `templates/`.
15
+
16
+ GitHub Pages deployment is configured via `.github/workflows/deploy.yml`; adjust the workflow or remove it if you handle deployments differently.
@@ -0,0 +1,24 @@
1
+ require 'rake/testtask'
2
+
3
+ desc 'Run test suite'
4
+ Rake::TestTask.new(:test) do |t|
5
+ t.libs << 'test'
6
+ t.pattern = 'test/**/*_test.rb'
7
+ end
8
+
9
+ desc 'Run RuboCop'
10
+ task :lint do
11
+ sh 'bundle exec rubocop'
12
+ end
13
+
14
+ desc 'Build the static site'
15
+ task :build do
16
+ sh 'bundle exec ruby_md_ssg build'
17
+ end
18
+
19
+ desc 'Serve the static site'
20
+ task :serve do
21
+ sh 'bundle exec ruby_md_ssg serve'
22
+ end
23
+
24
+ task default: :test
@@ -0,0 +1,12 @@
1
+ // Base script for Ruby MD SSG
2
+ document.addEventListener('DOMContentLoaded', () => {
3
+ const currentPath = window.location.pathname.replace(/\/$/, '') || '/';
4
+ document
5
+ .querySelectorAll('.layout__menu a')
6
+ .forEach((link) => {
7
+ const linkPath = link.getAttribute('href').replace(/\/$/, '') || '/';
8
+ if (linkPath === currentPath) {
9
+ link.classList.add('is-active');
10
+ }
11
+ });
12
+ });
@@ -0,0 +1,106 @@
1
+ /* Base layout for Ruby MD SSG */
2
+ * {
3
+ box-sizing: border-box;
4
+ }
5
+
6
+ body {
7
+ margin: 0;
8
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
9
+ background: #f8f9fb;
10
+ color: #111;
11
+ }
12
+
13
+ a {
14
+ color: #0366d6;
15
+ text-decoration: none;
16
+ }
17
+
18
+ a:hover {
19
+ text-decoration: underline;
20
+ }
21
+
22
+ .layout {
23
+ display: grid;
24
+ grid-template-columns: 320px 1fr;
25
+ min-height: 100vh;
26
+ }
27
+
28
+ .layout__sidebar {
29
+ background: #111827;
30
+ color: #f8f9fb;
31
+ padding: 2rem;
32
+ position: sticky;
33
+ top: 0;
34
+ align-self: start;
35
+ height: 100vh;
36
+ overflow-y: auto;
37
+ }
38
+
39
+ .layout__header h1 {
40
+ margin: 0;
41
+ font-size: 1.5rem;
42
+ }
43
+
44
+ .layout__header p {
45
+ margin: 0.5rem 0 1.5rem;
46
+ color: rgba(248, 249, 251, 0.7);
47
+ }
48
+
49
+ .layout__menu {
50
+ display: grid;
51
+ gap: 1.5rem;
52
+ }
53
+
54
+ .menu-section h2 {
55
+ margin: 0;
56
+ font-size: 0.875rem;
57
+ text-transform: uppercase;
58
+ letter-spacing: 0.08em;
59
+ color: rgba(248, 249, 251, 0.6);
60
+ }
61
+
62
+ .menu-section ul {
63
+ list-style: none;
64
+ padding: 0;
65
+ margin: 0.75rem 0 0;
66
+ display: grid;
67
+ gap: 0.4rem;
68
+ }
69
+
70
+ .menu-section a {
71
+ color: #f8f9fb;
72
+ display: block;
73
+ padding: 0.25rem 0;
74
+ }
75
+
76
+ .menu-section a.is-active {
77
+ font-weight: 600;
78
+ color: #38bdf8;
79
+ }
80
+
81
+ .layout__content {
82
+ padding: 3rem;
83
+ background: white;
84
+ }
85
+
86
+ .layout__content article {
87
+ max-width: 720px;
88
+ }
89
+
90
+ .layout__content h1 {
91
+ font-size: 2.25rem;
92
+ margin-top: 0;
93
+ }
94
+
95
+ .layout__content p {
96
+ line-height: 1.6;
97
+ font-size: 1rem;
98
+ }
99
+
100
+ .layout__content pre {
101
+ background: #1f2937;
102
+ color: #f8f9fb;
103
+ padding: 1rem;
104
+ border-radius: 6px;
105
+ overflow: auto;
106
+ }
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require 'ruby_md_ssg'
5
+
6
+ RubyMdSsg::CLI.start(['build', *ARGV])
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require 'ruby_md_ssg'
5
+
6
+ RubyMdSsg::CLI.start(['build', *ARGV])
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require 'ruby_md_ssg'
5
+
6
+ RubyMdSsg::CLI.start(['menu', *ARGV])
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require 'ruby_md_ssg'
5
+
6
+ RubyMdSsg::CLI.start(['serve', *ARGV])
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ env = {}
5
+ env['TEST'] = ENV['TEST'] if ENV['TEST']
6
+ env['TESTOPTS'] = ENV['TESTOPTS'] if ENV['TESTOPTS']
7
+
8
+ exec(env, 'bundle', 'exec', 'rake', 'test')
@@ -0,0 +1,11 @@
1
+ # Ruby MD SSG Launch Notes
2
+
3
+ Welcome to the first release of Ruby MD SSG. The goal is to build static sites with a tiny toolchain.
4
+
5
+ ## Highlights
6
+
7
+ - Full-site rebuild in a single command.
8
+ - Navigation driven by human-editable YAML.
9
+ - Zero JavaScript build tooling required.
10
+
11
+ Tell us what features you need next by opening an issue.
@@ -0,0 +1,7 @@
1
+ # Getting Started
2
+
3
+ 1. Install Ruby 3.2 or newer.
4
+ 2. Run `bundle install` inside the repository.
5
+ 3. Execute `bundle exec ruby_md_ssg build` to generate the static site. This also emits `build/sitemap.xml`; set `RUBY_MD_SSG_BASE_URL` or pass `--base-url` to control URLs.
6
+
7
+ You can now open the files in the `build/` directory or run the server command to preview locally.
@@ -0,0 +1,7 @@
1
+ # Recommended Workflow
2
+
3
+ - Write new content in `docs/` using markdown headings to outline the story.
4
+ - Regenerate the menu with `bundle exec ruby_md_ssg menu` so the navigation picks up new pages.
5
+ - Rebuild the site with `bundle exec ruby_md_ssg build` and review the output (including `build/sitemap.xml`) before opening a pull request.
6
+
7
+ Keep each page focused on a single topic and prefer relative links when referencing peer documents.
@@ -0,0 +1,5 @@
1
+ # Civic Technology Ideas
2
+
3
+ - Publish budget highlights in plain language.
4
+ - Host open office hours for every department.
5
+ - Track public project milestones transparently.
@@ -0,0 +1,8 @@
1
+ ---
2
+ title: <%= helpers.human_project_name %>
3
+ description: Site generated with Ruby MD SSG <%= gem_version %>.
4
+ ---
5
+
6
+ # Welcome to <%= helpers.human_project_name %>
7
+
8
+ Ruby MD SSG converts markdown content into a static site with a shared layout. Update this file to change the landing page.
@@ -0,0 +1,42 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title><%= title %> · Ruby MD SSG</title>
7
+ <% if meta_description && !meta_description.empty? %>
8
+ <meta name="description" content="<%= meta_description %>" />
9
+ <% end %>
10
+ <link rel="stylesheet" href="/assets/style.css" />
11
+ <script defer src="/assets/app.js"></script>
12
+ </head>
13
+ <body>
14
+ <div class="layout">
15
+ <aside class="layout__sidebar">
16
+ <header class="layout__header">
17
+ <h1>Ruby MD SSG</h1>
18
+ <p>Markdown to static site with minimal tooling.</p>
19
+ </header>
20
+ <nav class="layout__menu">
21
+ <% menu.sections.each do |section| %>
22
+ <section class="menu-section">
23
+ <h2><%= section.title %></h2>
24
+ <ul>
25
+ <% section.links.each do |link| %>
26
+ <li>
27
+ <a href="<%= link.route %>"><%= link.label %></a>
28
+ </li>
29
+ <% end %>
30
+ </ul>
31
+ </section>
32
+ <% end %>
33
+ </nav>
34
+ </aside>
35
+ <main class="layout__content">
36
+ <article>
37
+ <%= body_html %>
38
+ </article>
39
+ </main>
40
+ </div>
41
+ </body>
42
+ </html>