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 +7 -0
- data/AGENTS.md +17 -0
- data/CONTEXT_LOG.md +6 -0
- data/README.md +17 -0
- data/exe/ruby_md_ssg +9 -0
- data/lib/ruby_md_ssg/cli.rb +251 -0
- data/lib/ruby_md_ssg/compiler.rb +115 -0
- data/lib/ruby_md_ssg/document.rb +100 -0
- data/lib/ruby_md_ssg/document_finder.rb +24 -0
- data/lib/ruby_md_ssg/html_formatter.rb +20 -0
- data/lib/ruby_md_ssg/layout_renderer.rb +33 -0
- data/lib/ruby_md_ssg/markdown_renderer.rb +11 -0
- data/lib/ruby_md_ssg/menu_config.rb +71 -0
- data/lib/ruby_md_ssg/menu_generator.rb +87 -0
- data/lib/ruby_md_ssg/paths.rb +46 -0
- data/lib/ruby_md_ssg/server.rb +120 -0
- data/lib/ruby_md_ssg/version.rb +5 -0
- data/lib/ruby_md_ssg.rb +14 -0
- data/template/site/Gemfile.erb +12 -0
- data/template/site/README.md.erb +16 -0
- data/template/site/Rakefile +24 -0
- data/template/site/assets/app.js +12 -0
- data/template/site/assets/style.css +106 -0
- data/template/site/bin/build +6 -0
- data/template/site/bin/compile +6 -0
- data/template/site/bin/generate_menu +6 -0
- data/template/site/bin/serve +6 -0
- data/template/site/bin/test +8 -0
- data/template/site/docs/blog/launch.md +11 -0
- data/template/site/docs/guides/setup.md +7 -0
- data/template/site/docs/guides/workflow.md +7 -0
- data/template/site/docs/ideas/government.md +5 -0
- data/template/site/docs/index.md.erb +8 -0
- data/template/site/templates/layout.html.erb +42 -0
- metadata +122 -0
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,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,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
|