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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: df65115f64a772c620d615e8b0bb752d340bc47821ffc9228a976369c8a65514
4
+ data.tar.gz: 1b0e4cfd186cb5b26c0d61051579ab13aec4b0ddf2afe7ec42f5d02d593e364f
5
+ SHA512:
6
+ metadata.gz: 283a2e7203583828fd569f7104b87f8b60a14991562070b315e6b057156fc0fe6015aa10eef6b6d4d371c25b626ef2ab2da943e3153bfff2c63852fc47e34a5e
7
+ data.tar.gz: f8fdc454c0b484223430bcc130e230fff533f472a8306230d9fe467b4d258ae98b7c86b6c3ecc8b1fd4b4a4ff6e2dcc511259643982fb9a4c30047157ca11b3d
data/AGENTS.md ADDED
@@ -0,0 +1,17 @@
1
+ # Maintainer Guide
2
+
3
+ ## Project Structure
4
+ Core code lives under `lib/ruby_md_ssg/` (compiler, CLI, server, helpers). Executables are in `exe/`, and the site scaffold lives in `template/site/`. Tests reside in `test/` and mirror the public API. The gem spec is `ruby-md-ssg.gemspec`.
5
+
6
+ ## Development Commands
7
+ Run `bundle install` after touching the gemspec. Use `bundle exec ruby bin/test` (wrapper around the Minitest suite) before publishing. `bundle exec ruby_md_ssg build` from a test project exercises the full pipeline and should emit `sitemap.xml`; set `RUBY_MD_SSG_BASE_URL` (or pass `--base-url`) to control sitemap URLs.
8
+
9
+ CI lives in `.github/workflows/test.yml` (runs RuboCop, then the test suite on pushes/PRs). Publishing is handled by `.github/workflows/release.yml`; configure the repository secret `RUBYGEMS_API_KEY` before dispatching the workflow or pushing a `v*` tag.
10
+
11
+ ## Releasing
12
+ 1. Update `lib/ruby_md_ssg/version.rb`.
13
+ 2. Run `bundle exec rake release` (after configuring credentials).
14
+ 3. Update downstream projects (e.g., `ruby-md-ssg-example`) to the new version.
15
+
16
+ ## Context Logging
17
+ See `CONTEXT_LOG.md` for recent architectural changes.
data/CONTEXT_LOG.md ADDED
@@ -0,0 +1,6 @@
1
+ ## 2024-10-08
2
+ - Extracted gem from the original project: contains library code, CLI, scaffold template, and test suite.
3
+ - Added helper-backed ERB templates for scaffolding and ensured tests cover project generation.
4
+ - Added GitHub Actions workflows for CI (`.github/workflows/test.yml`) and RubyGems publishing (`.github/workflows/release.yml`).
5
+ - Renamed gem to `ruby-md-ssg`, updated namespaces/commands, and refreshed documentation and tests to reflect the new branding.
6
+ - Compiler now emits `sitemap.xml` (with optional `--base-url` support), CI runs RuboCop before executing tests, and the scaffold ships a GitHub Pages deploy workflow.
data/README.md ADDED
@@ -0,0 +1,17 @@
1
+ # Ruby MD SSG
2
+
3
+ The `ruby-md-ssg` gem exposes a command-line interface for building and serving markdown-driven static sites.
4
+
5
+ ## Development
6
+
7
+ ```bash
8
+ bundle install
9
+ bundle exec ruby bin/test
10
+ ```
11
+
12
+ ## CLI
13
+
14
+ - `ruby_md_ssg new my-site` — scaffold a new project using the bundled template
15
+ - `ruby_md_ssg build` — regenerate the site into `build/` (also emits `sitemap.xml`; pass `--base-url` to control absolute URLs)
16
+ - `ruby_md_ssg serve` — serve the site locally with automatic rebuilds
17
+ - `ruby_md_ssg menu` — refresh `docs/menu.yml`
data/exe/ruby_md_ssg ADDED
@@ -0,0 +1,9 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ lib_path = File.expand_path('../lib', __dir__)
5
+ $LOAD_PATH.unshift(lib_path) if Dir.exist?(lib_path)
6
+
7
+ require 'ruby_md_ssg'
8
+
9
+ RubyMdSsg::CLI.start(ARGV)
@@ -0,0 +1,251 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'optparse'
4
+ require 'fileutils'
5
+ require 'erb'
6
+
7
+ module RubyMdSsg
8
+ class CLI
9
+ BINDING_STRUCT = Struct.new(
10
+ :project_name,
11
+ :module_name,
12
+ :gem_version,
13
+ :helpers,
14
+ keyword_init: true
15
+ ) do
16
+ def to_binding
17
+ binding
18
+ end
19
+ end
20
+ private_constant :BINDING_STRUCT
21
+
22
+ TEMPLATE_DIR = File.expand_path('../../template/site', __dir__)
23
+
24
+ def self.start(argv)
25
+ new(argv).run
26
+ end
27
+
28
+ def initialize(argv)
29
+ @argv = argv.dup
30
+ end
31
+
32
+ def run
33
+ command = argv.shift
34
+ case command
35
+ when 'new'
36
+ handle_new(argv)
37
+ when 'build'
38
+ handle_build(argv)
39
+ when 'serve'
40
+ handle_serve(argv)
41
+ when 'menu'
42
+ handle_menu(argv)
43
+ when 'version', '--version', '-v'
44
+ puts "Ruby MD SSG #{RubyMdSsg::VERSION}"
45
+ else
46
+ puts usage
47
+ command ? exit(1) : exit(0)
48
+ end
49
+ end
50
+
51
+ private
52
+
53
+ attr_reader :argv
54
+
55
+ def usage
56
+ <<~TEXT
57
+ Usage: ruby_md_ssg <command> [options]
58
+
59
+ Commands:
60
+ new NAME # Scaffold a new Ruby MD SSG project
61
+ build # Generate the site into the build directory (writes sitemap.xml)
62
+ serve # Serve the site locally with rebuilds
63
+ menu # Regenerate docs/menu.yml from docs/
64
+ version # Show gem version
65
+ TEXT
66
+ end
67
+
68
+ def handle_new(args)
69
+ options = { path: nil }
70
+ parser = OptionParser.new do |opts|
71
+ opts.banner = 'Usage: ruby_md_ssg new NAME [options]'
72
+ opts.on('-pPATH', '--path=PATH', 'Target directory (defaults to NAME)') do |path|
73
+ options[:path] = File.expand_path(path)
74
+ end
75
+ end
76
+ parser.parse!(args)
77
+
78
+ project_name = args.shift
79
+ unless project_name
80
+ warn 'Project name is required.'
81
+ puts parser
82
+ exit 1
83
+ end
84
+
85
+ destination = options[:path] || File.expand_path(project_name)
86
+ Scaffold.new(project_name, destination).run
87
+ puts "Created Ruby MD SSG project at #{destination}"
88
+ end
89
+
90
+ def handle_build(args)
91
+ RubyMdSsg::Paths.reset!
92
+ RubyMdSsg::Paths.root = Dir.pwd
93
+ options = build_options(args)
94
+
95
+ generator = RubyMdSsg::MenuGenerator.new(docs_dir: options[:docs], menu_path: options[:menu])
96
+ generator.generate
97
+
98
+ compiler = RubyMdSsg::Compiler.new(
99
+ docs_dir: options[:docs],
100
+ build_dir: options[:build],
101
+ assets_dir: options[:assets],
102
+ menu_path: options[:menu],
103
+ base_url: options[:base_url]
104
+ )
105
+ compiler.compile
106
+ end
107
+
108
+ def handle_menu(args)
109
+ RubyMdSsg::Paths.reset!
110
+ RubyMdSsg::Paths.root = Dir.pwd
111
+ options = build_options(args)
112
+ generator = RubyMdSsg::MenuGenerator.new(docs_dir: options[:docs], menu_path: options[:menu])
113
+ generator.generate
114
+ end
115
+
116
+ def handle_serve(args)
117
+ RubyMdSsg::Paths.reset!
118
+ RubyMdSsg::Paths.root = Dir.pwd
119
+ options = build_options(args)
120
+ RubyMdSsg::Server.start(options)
121
+ end
122
+
123
+ def build_options(args)
124
+ options = {}
125
+ parser = OptionParser.new do |opts|
126
+ opts.banner = 'Options:'
127
+ opts.on('--docs PATH', 'Directory containing markdown docs') do |path|
128
+ options[:docs] = File.expand_path(path)
129
+ end
130
+ opts.on('--build PATH', 'Build output directory') do |path|
131
+ options[:build] = File.expand_path(path)
132
+ end
133
+ opts.on('--menu PATH', 'Menu configuration path') do |path|
134
+ options[:menu] = File.expand_path(path)
135
+ end
136
+ opts.on('--assets PATH', 'Assets directory') do |path|
137
+ options[:assets] = File.expand_path(path)
138
+ end
139
+ opts.on(
140
+ '--base-url URL',
141
+ 'Base URL for sitemap entries (defaults to ENV RUBY_MD_SSG_BASE_URL)'
142
+ ) do |url|
143
+ options[:base_url] = url
144
+ end
145
+ opts.on('--port PORT', Integer, 'Port for serve command (default: 4000)') do |port|
146
+ options[:port] = port
147
+ end
148
+ opts.on('--[no-]auto-build', 'Serve only: build before starting (default: true)') do |flag|
149
+ options[:auto_build] = flag
150
+ end
151
+ opts.on('--[no-]watch', 'Serve only: watch for changes (default: true)') do |flag|
152
+ options[:watch] = flag
153
+ end
154
+ opts.on(
155
+ '--interval SECONDS', Float,
156
+ 'Serve only: watch interval (default: 1.0)'
157
+ ) do |seconds|
158
+ options[:interval] = seconds
159
+ end
160
+ end
161
+ parser.parse!(args)
162
+
163
+ options[:docs] ||= RubyMdSsg::Paths.docs_dir
164
+ options[:build] ||= RubyMdSsg::Paths.build_dir
165
+ options[:menu] ||= RubyMdSsg::Paths.menu_config
166
+ options[:assets] ||= RubyMdSsg::Paths.assets_dir
167
+ options[:base_url] ||= ENV.fetch('RUBY_MD_SSG_BASE_URL', nil)
168
+ options.delete_if { |_key, value| value.nil? }
169
+ end
170
+
171
+ class Scaffold
172
+ attr_reader :project_name, :destination
173
+
174
+ def initialize(project_name, destination)
175
+ @project_name = project_name
176
+ @destination = destination
177
+ end
178
+
179
+ def run
180
+ raise "Directory already exists: #{destination}" if File.exist?(destination)
181
+
182
+ FileUtils.mkdir_p(destination)
183
+ copy_template
184
+ post_process
185
+ end
186
+
187
+ private
188
+
189
+ def copy_template
190
+ Dir.glob(File.join(TEMPLATE_DIR, '**', '*'), File::FNM_DOTMATCH).each do |source|
191
+ next if ['.', '..'].include?(File.basename(source))
192
+
193
+ relative = source.delete_prefix("#{TEMPLATE_DIR}/")
194
+ templated = source.end_with?('.erb') && !relative.start_with?('templates/')
195
+ target_relative = templated ? relative.sub(/\.erb\z/, '') : relative
196
+ target = File.join(destination, target_relative)
197
+
198
+ if File.directory?(source)
199
+ FileUtils.mkdir_p(target)
200
+ else
201
+ content = if templated
202
+ ERB.new(File.read(source), trim_mode: '-').result(binding_context)
203
+ else
204
+ File.binread(source)
205
+ end
206
+ FileUtils.mkdir_p(File.dirname(target))
207
+ File.write(target, content)
208
+ File.chmod(File.stat(source).mode & 0o777, target)
209
+ end
210
+ end
211
+ end
212
+
213
+ def post_process
214
+ FileUtils.mkdir_p(File.join(destination, 'build'))
215
+ end
216
+
217
+ def binding_context
218
+ BINDING_STRUCT.new(
219
+ project_name: project_name,
220
+ module_name: module_name,
221
+ gem_version: RubyMdSsg::VERSION,
222
+ helpers: HelperMethods.new(project_name)
223
+ ).to_binding
224
+ end
225
+
226
+ def module_name
227
+ normalize_identifier(project_name)
228
+ end
229
+
230
+ def normalize_identifier(value)
231
+ value.split(/[^a-zA-Z0-9]/).reject(&:empty?).map(&:capitalize).join
232
+ end
233
+
234
+ class HelperMethods
235
+ def initialize(project_name)
236
+ @project_name = project_name
237
+ end
238
+
239
+ def human_project_name
240
+ @human_project_name ||= begin
241
+ parts = @project_name
242
+ .split(/[^a-zA-Z0-9]/)
243
+ .reject(&:empty?)
244
+ .map(&:capitalize)
245
+ parts.join(' ')
246
+ end
247
+ end
248
+ end
249
+ end
250
+ end
251
+ end
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+ require 'nokogiri'
5
+ require_relative 'paths'
6
+ require_relative 'document_finder'
7
+ require_relative 'menu_config'
8
+ require_relative 'markdown_renderer'
9
+ require_relative 'layout_renderer'
10
+ require_relative 'html_formatter'
11
+
12
+ module RubyMdSsg
13
+ class Compiler
14
+ def initialize(
15
+ docs_dir: Paths.docs_dir,
16
+ build_dir: Paths.build_dir,
17
+ assets_dir: Paths.assets_dir,
18
+ menu_path: Paths.menu_config,
19
+ base_url: nil
20
+ )
21
+ @docs_dir = docs_dir
22
+ @build_dir = build_dir
23
+ @assets_dir = assets_dir
24
+ @menu_path = menu_path
25
+ @base_url = base_url
26
+ @markdown = MarkdownRenderer.new
27
+ @layout = LayoutRenderer.new
28
+ @html_formatter = HtmlFormatter.new
29
+ end
30
+
31
+ def compile
32
+ purge_build
33
+ ensure_build_structure
34
+
35
+ documents = DocumentFinder.new(docs_dir: docs_dir).all
36
+ menu = menu_from_configuration(documents)
37
+
38
+ documents.each do |document|
39
+ render_document(document, menu)
40
+ end
41
+
42
+ copy_assets
43
+ generate_sitemap(documents)
44
+ end
45
+
46
+ private
47
+
48
+ attr_reader :docs_dir, :build_dir, :assets_dir, :menu_path,
49
+ :markdown, :layout, :html_formatter, :base_url
50
+
51
+ def purge_build
52
+ FileUtils.rm_rf(build_dir)
53
+ end
54
+
55
+ def ensure_build_structure
56
+ FileUtils.mkdir_p(build_dir)
57
+ FileUtils.mkdir_p(File.join(build_dir, 'assets'))
58
+ end
59
+
60
+ def menu_from_configuration(documents)
61
+ if File.exist?(menu_path)
62
+ MenuConfig.load(menu_path)
63
+ else
64
+ MenuConfig.from_documents(documents)
65
+ end
66
+ end
67
+
68
+ def render_document(document, menu)
69
+ html_body = markdown.render(document.body_markdown)
70
+ page = layout.render(document: document, body_html: html_body, menu: menu)
71
+ page = html_formatter.format(page)
72
+ output_path = document.output_path
73
+ FileUtils.mkdir_p(File.dirname(output_path))
74
+ File.write(output_path, page)
75
+ end
76
+
77
+ def copy_assets
78
+ copy_asset('style.css')
79
+ copy_asset('app.js')
80
+ end
81
+
82
+ def copy_asset(filename)
83
+ source = File.join(assets_dir, filename)
84
+ return unless File.exist?(source)
85
+
86
+ destination = File.join(build_dir, 'assets', filename)
87
+ FileUtils.cp(source, destination)
88
+ end
89
+
90
+ def generate_sitemap(documents)
91
+ return if documents.empty?
92
+
93
+ builder = Nokogiri::XML::Builder.new(encoding: 'UTF-8') do |xml|
94
+ xml.urlset('xmlns' => 'http://www.sitemaps.org/schemas/sitemap/0.9') do
95
+ documents.each do |document|
96
+ xml.url do
97
+ xml.loc sitemap_location_for(document.route)
98
+ xml.lastmod(document.last_modified.utc.iso8601)
99
+ end
100
+ end
101
+ end
102
+ end
103
+
104
+ File.write(File.join(build_dir, 'sitemap.xml'), builder.to_xml)
105
+ end
106
+
107
+ def sitemap_location_for(route)
108
+ normalized_route = route == '/' ? '/' : route
109
+ return normalized_route if base_url.nil? || base_url.empty?
110
+
111
+ normalized_base = base_url.end_with?('/') ? base_url.chomp('/') : base_url
112
+ normalized_base + normalized_route
113
+ end
114
+ end
115
+ end
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'yaml'
4
+ require_relative 'paths'
5
+
6
+ module RubyMdSsg
7
+ # Represents a markdown document and its derived site metadata.
8
+ class Document
9
+ attr_reader :source_path, :docs_dir
10
+
11
+ def initialize(source_path, docs_dir: Paths.docs_dir)
12
+ @source_path = source_path
13
+ @docs_dir = docs_dir
14
+ end
15
+
16
+ def relative_path
17
+ @relative_path ||= begin
18
+ prefix = "#{docs_dir}/"
19
+ if source_path.start_with?(prefix)
20
+ source_path.delete_prefix(prefix)
21
+ else
22
+ source_path
23
+ end
24
+ end
25
+ end
26
+
27
+ def route
28
+ return '/' if relative_path == 'index.md'
29
+
30
+ "/#{relative_path.sub(/\.md\z/, '')}"
31
+ end
32
+
33
+ def output_dir
34
+ File.join(Paths.build_dir, route.delete_prefix('/'))
35
+ end
36
+
37
+ def output_path
38
+ if route == '/'
39
+ File.join(Paths.build_dir, 'index.html')
40
+ else
41
+ File.join(output_dir, 'index.html')
42
+ end
43
+ end
44
+
45
+ def title
46
+ @title ||= title_from_frontmatter || title_from_heading || default_title
47
+ end
48
+
49
+ def content
50
+ @content ||= File.read(source_path)
51
+ end
52
+
53
+ def last_modified
54
+ File.mtime(source_path)
55
+ end
56
+
57
+ def meta_description
58
+ @meta_description ||= begin
59
+ value = frontmatter['description'] || frontmatter['meta_description']
60
+ value&.to_s&.strip
61
+ end
62
+ end
63
+
64
+ def body_markdown
65
+ frontmatter_match = content.match(/\A---\s*\n(.*?)\n---\s*\n/m)
66
+ return content unless frontmatter_match
67
+
68
+ content.sub(frontmatter_match[0], '')
69
+ end
70
+
71
+ def frontmatter
72
+ return @frontmatter if defined?(@frontmatter)
73
+
74
+ match = content.match(/\A---\s*\n(.*?)\n---\s*\n/m)
75
+ @frontmatter = if match
76
+ YAML.safe_load(match[1]) || {}
77
+ else
78
+ {}
79
+ end
80
+ end
81
+
82
+ private
83
+
84
+ def title_from_frontmatter
85
+ frontmatter['title']
86
+ end
87
+
88
+ def title_from_heading
89
+ line = content.each_line.find { |l| l.start_with?('# ') }
90
+ return unless line
91
+
92
+ heading = line.split('# ', 2).last
93
+ heading&.strip
94
+ end
95
+
96
+ def default_title
97
+ File.basename(relative_path, '.md').tr('_-', ' ').split.map(&:capitalize).join(' ')
98
+ end
99
+ end
100
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'paths'
4
+ require_relative 'document'
5
+
6
+ module RubyMdSsg
7
+ class DocumentFinder
8
+ def initialize(docs_dir: Paths.docs_dir)
9
+ @docs_dir = docs_dir
10
+ end
11
+
12
+ def all
13
+ markdown_files.map { |path| Document.new(path, docs_dir: docs_dir) }
14
+ end
15
+
16
+ private
17
+
18
+ attr_reader :docs_dir
19
+
20
+ def markdown_files
21
+ Dir.glob(File.join(docs_dir, '**', '*.md'), sort: true)
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'nokogiri'
4
+
5
+ module RubyMdSsg
6
+ class HtmlFormatter
7
+ def initialize(indent: 2)
8
+ @indent = indent
9
+ end
10
+
11
+ def format(html)
12
+ document = Nokogiri::HTML5.parse(html)
13
+ document.to_html(indent: indent)
14
+ end
15
+
16
+ private
17
+
18
+ attr_reader :indent
19
+ end
20
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'erb'
4
+ require_relative 'paths'
5
+
6
+ module RubyMdSsg
7
+ class LayoutRenderer
8
+ def initialize(template_path: default_template_path)
9
+ @template_path = template_path
10
+ end
11
+
12
+ def render(document:, body_html:, menu:)
13
+ template.result_with_hash(
14
+ title: document.title,
15
+ body_html: body_html,
16
+ menu: menu,
17
+ meta_description: document.meta_description
18
+ )
19
+ end
20
+
21
+ private
22
+
23
+ attr_reader :template_path
24
+
25
+ def template
26
+ @template ||= ERB.new(File.read(template_path))
27
+ end
28
+
29
+ def default_template_path
30
+ File.join(Paths.root, 'templates', 'layout.html.erb')
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'kramdown'
4
+
5
+ module RubyMdSsg
6
+ class MarkdownRenderer
7
+ def render(markdown)
8
+ Kramdown::Document.new(markdown).to_html
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'yaml'
4
+ require_relative 'paths'
5
+
6
+ module RubyMdSsg
7
+ class MenuConfig
8
+ Section = Struct.new(:title, :links, keyword_init: true)
9
+ Link = Struct.new(:label, :route, keyword_init: true)
10
+
11
+ def self.load(path = Paths.menu_config)
12
+ return new([]) unless File.exist?(path)
13
+
14
+ raw = YAML.safe_load_file(path) || {}
15
+ sections = Array(raw['sections']).map do |section|
16
+ Section.new(
17
+ title: section['title'],
18
+ links: Array(section['links']).map do |link|
19
+ Link.new(label: link['label'], route: link['route'])
20
+ end
21
+ )
22
+ end
23
+ new(sections)
24
+ end
25
+
26
+ def self.from_documents(documents)
27
+ grouped = documents.group_by { |doc| top_level_segment(doc.route) }
28
+ sections = grouped.keys.sort.map do |segment|
29
+ docs_in_group = grouped[segment]
30
+ Section.new(
31
+ title: segment_title(segment),
32
+ links: docs_in_group.sort_by(&:route).map do |doc|
33
+ Link.new(label: doc.title, route: doc.route)
34
+ end
35
+ )
36
+ end
37
+ new(sections)
38
+ end
39
+
40
+ def initialize(sections)
41
+ @sections = sections
42
+ end
43
+
44
+ attr_reader :sections
45
+
46
+ def to_h
47
+ {
48
+ 'sections' => sections.map do |section|
49
+ {
50
+ 'title' => section.title,
51
+ 'links' => section.links.map do |link|
52
+ { 'label' => link.label, 'route' => link.route }
53
+ end
54
+ }
55
+ end
56
+ }
57
+ end
58
+
59
+ private_class_method def self.top_level_segment(route)
60
+ return 'home' if route == '/'
61
+
62
+ route.split('/')[1]
63
+ end
64
+
65
+ private_class_method def self.segment_title(segment)
66
+ return 'Home' if segment == 'home'
67
+
68
+ segment.to_s.tr('_-', ' ').split.map(&:capitalize).join(' ')
69
+ end
70
+ end
71
+ end