jackdaw 1.0.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,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jackdaw
4
+ #
5
+ # Project structure management
6
+ #
7
+ # Defines the standard Jackdaw project structure and provides path helpers.
8
+ # All Jackdaw projects follow this convention:
9
+ #
10
+ # my-site.site/
11
+ # ├── site/
12
+ # │ ├── src/ # Content files (*.md)
13
+ # │ ├── templates/ # ERB templates (*.html.erb)
14
+ # │ └── assets/ # Static assets (images, CSS, JS)
15
+ # └── public/ # Generated output
16
+ #
17
+ # This convention-over-configuration approach eliminates the need for
18
+ # configuration files while maintaining clear project organization.
19
+ #
20
+ class Project
21
+ attr_reader :root
22
+
23
+ # Initialize a project at the given root directory
24
+ #
25
+ # @param root [String] Path to project root (defaults to current directory)
26
+ def initialize(root = Dir.pwd)
27
+ @root = File.expand_path(root)
28
+ end
29
+
30
+ # Path to the site directory containing all source files
31
+ #
32
+ # @return [String] Absolute path to site/
33
+ def site_dir
34
+ File.join(root, 'site')
35
+ end
36
+
37
+ # Path to content source directory
38
+ #
39
+ # @return [String] Absolute path to site/src/
40
+ def src_dir
41
+ File.join(site_dir, 'src')
42
+ end
43
+
44
+ # Path to templates directory
45
+ #
46
+ # @return [String] Absolute path to site/templates/
47
+ def templates_dir
48
+ File.join(site_dir, 'templates')
49
+ end
50
+
51
+ # Path to assets directory
52
+ #
53
+ # @return [String] Absolute path to site/assets/
54
+ def assets_dir
55
+ File.join(site_dir, 'assets')
56
+ end
57
+
58
+ # Path to output directory for generated site
59
+ #
60
+ # @return [String] Absolute path to public/
61
+ def output_dir
62
+ File.join(root, 'public')
63
+ end
64
+
65
+ # Check if this is a valid Jackdaw project
66
+ #
67
+ # @return [Boolean] true if site/ directory exists
68
+ def exists?
69
+ Dir.exist?(site_dir)
70
+ end
71
+
72
+ # Create the standard project directory structure
73
+ #
74
+ # @return [void]
75
+ def create!
76
+ dirs = [site_dir, src_dir, templates_dir, assets_dir, output_dir]
77
+ dirs.each { |dir| FileUtils.mkdir_p(dir) }
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,154 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jackdaw
4
+ # Renders content using ERB templates with layouts and partials
5
+ class Renderer
6
+ attr_reader :project, :scanner
7
+
8
+ def initialize(project)
9
+ @project = project
10
+ @scanner = Scanner.new(project)
11
+ @template_cache = {}
12
+ end
13
+
14
+ # Render a content file to HTML
15
+ def render_content(content_file)
16
+ # Get the template for this content type
17
+ template = find_template(content_file.type)
18
+ raise Error, "Template not found for type: #{content_file.type}" unless template
19
+
20
+ # Build context
21
+ context = build_context(content_file)
22
+
23
+ # Render markdown to HTML
24
+ html_content = render_markdown(content_file.content)
25
+
26
+ # Render template with content
27
+ template_html = render_template(template, context.merge(content: html_content))
28
+
29
+ # Wrap in layout if it exists
30
+ render_layout(template_html, context)
31
+ end
32
+
33
+ # Render a partial
34
+ def render_partial(name, context = {})
35
+ partial_path = File.join(project.templates_dir, "_#{name}.html.erb")
36
+ raise Error, "Partial not found: #{name}" unless File.exist?(partial_path)
37
+
38
+ template = load_template(partial_path)
39
+ render_template(template, context)
40
+ end
41
+
42
+ private
43
+
44
+ def find_template(type)
45
+ template_path = File.join(project.templates_dir, "#{type}.html.erb")
46
+ File.exist?(template_path) ? load_template(template_path) : nil
47
+ end
48
+
49
+ def load_template(path)
50
+ # Cache compiled templates
51
+ mtime = File.mtime(path)
52
+ cache_key = "#{path}:#{mtime.to_i}"
53
+
54
+ @template_cache[cache_key] ||= ERB.new(File.read(path), trim_mode: '-')
55
+ end
56
+
57
+ def render_template(template, context)
58
+ binding_context = TemplateContext.new(context, self)
59
+ template.result(binding_context.template_binding)
60
+ end
61
+
62
+ def render_markdown(markdown_content)
63
+ Kramdown::Document.new(
64
+ markdown_content,
65
+ input: 'GFM',
66
+ syntax_highlighter: 'rouge',
67
+ syntax_highlighter_opts: {
68
+ line_numbers: false,
69
+ css_class: 'highlight'
70
+ }
71
+ ).to_html
72
+ end
73
+
74
+ def render_layout(content, context)
75
+ layout_path = File.join(project.templates_dir, 'layout.html.erb')
76
+ return content unless File.exist?(layout_path)
77
+
78
+ layout = load_template(layout_path)
79
+ render_template(layout, context.merge(content: content))
80
+ end
81
+
82
+ def build_context(content_file)
83
+ {
84
+ title: content_file.title,
85
+ date: content_file.date,
86
+ type: content_file.type,
87
+ slug: content_file.slug,
88
+ path: content_file.output_path,
89
+ excerpt: content_file.excerpt,
90
+ reading_time: content_file.reading_time,
91
+ site_name: infer_site_name,
92
+ all_posts: all_posts,
93
+ all_pages: all_pages
94
+ }
95
+ end
96
+
97
+ def infer_site_name
98
+ # Extract site name from folder structure (e.g., "my-blog.site" -> "my-blog")
99
+ project_name = File.basename(project.root)
100
+ project_name.sub(/\.site$/, '').tr('-', ' ').split.map(&:capitalize).join(' ')
101
+ end
102
+
103
+ def all_posts
104
+ @all_posts ||= scanner.content_files
105
+ .select { |f| %w[blog post].include?(f.type) }
106
+ .sort_by(&:date)
107
+ .reverse
108
+ end
109
+
110
+ def all_pages
111
+ @all_pages ||= scanner.content_files
112
+ .select { |f| f.type == 'page' }
113
+ .sort_by(&:title)
114
+ end
115
+ end
116
+
117
+ # Template context for ERB binding
118
+ class TemplateContext
119
+ include Jackdaw::SEOHelpers
120
+
121
+ def initialize(context, renderer)
122
+ @context = context
123
+ @renderer = renderer
124
+
125
+ # Make all context variables available as instance variables
126
+ context.each do |key, value|
127
+ instance_variable_set("@#{key}", value)
128
+ end
129
+ end
130
+
131
+ # Expose binding for ERB
132
+ def template_binding
133
+ binding
134
+ end
135
+
136
+ # Make context variables available as methods
137
+ def method_missing(method_name, *args)
138
+ if @context.key?(method_name)
139
+ @context[method_name]
140
+ else
141
+ super
142
+ end
143
+ end
144
+
145
+ def respond_to_missing?(method_name, include_private = false)
146
+ @context.key?(method_name) || super
147
+ end
148
+
149
+ # Render partial helper
150
+ def render(partial_name, local_context = {})
151
+ @renderer.render_partial(partial_name, @context.merge(local_context))
152
+ end
153
+ end
154
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jackdaw
4
+ # Scans directories and discovers content files
5
+ class Scanner
6
+ attr_reader :project
7
+
8
+ def initialize(project)
9
+ @project = project
10
+ end
11
+
12
+ # Scan for all content files in src/ directory
13
+ def content_files
14
+ return [] unless Dir.exist?(project.src_dir)
15
+
16
+ Dir.glob(File.join(project.src_dir, '**', '*.*.md')).map do |path|
17
+ ContentFile.new(path, project)
18
+ end
19
+ end
20
+
21
+ # Scan for all template files in templates/ directory
22
+ def template_files
23
+ return [] unless Dir.exist?(project.templates_dir)
24
+
25
+ Dir.glob(File.join(project.templates_dir, '*.html.erb')).map do |path|
26
+ TemplateFile.new(path, project)
27
+ end
28
+ end
29
+
30
+ # Scan for all asset files in assets/ directory
31
+ def asset_files
32
+ return [] unless Dir.exist?(project.assets_dir)
33
+
34
+ Dir.glob(File.join(project.assets_dir, '**', '*')).reject { |p| File.directory?(p) }.map do |path|
35
+ AssetFile.new(path, project)
36
+ end
37
+ end
38
+
39
+ # Get all files
40
+ def all_files
41
+ content_files + template_files + asset_files
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jackdaw
4
+ # SEO meta tag helpers for templates
5
+ module SEOHelpers
6
+ # Generate Open Graph meta tags
7
+ def og_tags(title:, description:, url:, type: 'website', image: nil)
8
+ tags = []
9
+ tags << %(<meta property="og:title" content="#{escape_html(title)}" />)
10
+ tags << %(<meta property="og:description" content="#{escape_html(description)}" />)
11
+ tags << %(<meta property="og:url" content="#{escape_html(url)}" />)
12
+ tags << %(<meta property="og:type" content="#{escape_html(type)}" />)
13
+ tags << %(<meta property="og:image" content="#{escape_html(image)}" />) if image
14
+
15
+ tags.join("\n ")
16
+ end
17
+
18
+ # Generate Twitter Card meta tags
19
+ def twitter_tags(title:, description:, card: 'summary', image: nil, site: nil, creator: nil)
20
+ tags = []
21
+ tags << %(<meta name="twitter:card" content="#{escape_html(card)}" />)
22
+ tags << %(<meta name="twitter:title" content="#{escape_html(title)}" />)
23
+ tags << %(<meta name="twitter:description" content="#{escape_html(description)}" />)
24
+ tags << %(<meta name="twitter:image" content="#{escape_html(image)}" />) if image
25
+ tags << %(<meta name="twitter:site" content="#{escape_html(site)}" />) if site
26
+ tags << %(<meta name="twitter:creator" content="#{escape_html(creator)}" />) if creator
27
+
28
+ tags.join("\n ")
29
+ end
30
+
31
+ # Generate canonical link tag
32
+ def canonical_tag(url)
33
+ %(<link rel="canonical" href="#{escape_html(url)}" />)
34
+ end
35
+
36
+ # Generate meta description tag
37
+ def meta_description(description)
38
+ %(<meta name="description" content="#{escape_html(description)}" />)
39
+ end
40
+
41
+ # Generate all basic SEO tags at once
42
+ def seo_tags(title:, description:, url:, image: nil, type: 'website')
43
+ tags = []
44
+ tags << meta_description(description)
45
+ tags << canonical_tag(url)
46
+ tags << og_tags(title: title, description: description, url: url, type: type, image: image)
47
+ tags << twitter_tags(title: title, description: description, image: image)
48
+
49
+ tags.join("\n ")
50
+ end
51
+
52
+ private
53
+
54
+ def escape_html(text)
55
+ return '' unless text
56
+
57
+ text.to_s
58
+ .gsub('&', '&amp;')
59
+ .gsub('<', '&lt;')
60
+ .gsub('>', '&gt;')
61
+ .gsub('"', '&quot;')
62
+ .gsub("'", '&#39;')
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,214 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jackdaw
4
+ # Development server with live reload
5
+ class Server
6
+ attr_reader :project, :builder, :watcher, :port, :host
7
+
8
+ def initialize(project, options = {})
9
+ @project = project
10
+ @builder = Builder.new(project, {})
11
+ @port = options[:port] || 4000
12
+ @host = options[:host] || 'localhost'
13
+ @livereload = options.fetch(:livereload, true)
14
+ @rebuilding = false
15
+ @clients = []
16
+ end
17
+
18
+ # Start the server
19
+ def start
20
+ # Initial build
21
+ puts "\n#{colorize('🚀 Building site...', :magenta)}"
22
+ stats = builder.build
23
+ show_build_stats(stats)
24
+
25
+ # Setup file watcher
26
+ setup_watcher if @livereload
27
+
28
+ # Start Puma
29
+ puts "\n#{colorize("⚡️ Server running at #{colorize("http://#{host}:#{port}", :cyan)}", :bold)}"
30
+ puts colorize('Press Ctrl+C to stop', :magenta)
31
+ puts ''
32
+
33
+ Rack::Handler::Puma.run(rack_app, Port: port, Host: host, Silent: true)
34
+ end
35
+
36
+ def livereload?
37
+ @livereload
38
+ end
39
+
40
+ private
41
+
42
+ def rack_app
43
+ server = self
44
+ Rack::Builder.new do
45
+ use Rack::CommonLogger
46
+ use LiveReloadMiddleware, server if server.livereload?
47
+ run StaticFileServer.new(server.project)
48
+ end
49
+ end
50
+
51
+ def setup_watcher
52
+ @watcher = Watcher.new(project)
53
+
54
+ @watcher.on_change do |changes|
55
+ next if @rebuilding
56
+
57
+ @rebuilding = true
58
+ Thread.new do
59
+ rebuild_site(changes)
60
+ ensure
61
+ @rebuilding = false
62
+ end
63
+ end
64
+
65
+ @watcher.start
66
+ end
67
+
68
+ def rebuild_site(changes)
69
+ changed_files = changes.values.flatten.length
70
+ puts "\n#{colorize('🔄 Rebuilding...', :cyan)} (#{changed_files} files changed)"
71
+
72
+ stats = builder.build
73
+ show_build_stats(stats)
74
+
75
+ notify_reload if @livereload
76
+ end
77
+
78
+ def show_build_stats(stats)
79
+ if stats.success?
80
+ puts "#{colorize('✓',
81
+ :green)} Built #{colorize(stats.files_built.to_s,
82
+ :cyan)} pages in #{colorize(format('%.2fs', stats.total_time),
83
+ :cyan)}"
84
+ else
85
+ puts colorize("✗ Build failed with #{stats.errors.length} errors", :yellow)
86
+ stats.errors.each { |e| puts " #{colorize('→', :yellow)} #{e.message}" }
87
+ end
88
+ end
89
+
90
+ def notify_reload
91
+ # In a real implementation, this would notify WebSocket clients
92
+ # For now, the LiveReloadMiddleware handles it with polling
93
+ end
94
+
95
+ def colorize(text, color)
96
+ colors = {
97
+ reset: "\e[0m",
98
+ bold: "\e[1m",
99
+ green: "\e[32m",
100
+ cyan: "\e[36m",
101
+ yellow: "\e[33m",
102
+ magenta: "\e[35m"
103
+ }
104
+ "#{colors[color]}#{text}#{colors[:reset]}"
105
+ end
106
+ end
107
+
108
+ # Static file server
109
+ class StaticFileServer
110
+ def initialize(project)
111
+ @project = project
112
+ end
113
+
114
+ def call(env)
115
+ path = Rack::Utils.unescape_path(env['PATH_INFO'])
116
+ file_path = File.join(@project.output_dir, path)
117
+
118
+ # Serve index.html for directories
119
+ file_path = File.join(file_path, 'index.html') if path.end_with?('/') || File.directory?(file_path)
120
+
121
+ # Add .html extension if file doesn't exist
122
+ unless File.exist?(file_path)
123
+ html_path = "#{file_path}.html"
124
+ file_path = html_path if File.exist?(html_path)
125
+ end
126
+
127
+ if File.exist?(file_path) && File.file?(file_path)
128
+ serve_file(file_path)
129
+ else
130
+ [404, { 'Content-Type' => 'text/html' }, ['<h1>404 Not Found</h1>']]
131
+ end
132
+ end
133
+
134
+ private
135
+
136
+ def serve_file(path)
137
+ content = File.read(path)
138
+ content_type = mime_type(path)
139
+
140
+ [200, { 'Content-Type' => content_type, 'Content-Length' => content.bytesize.to_s }, [content]]
141
+ end
142
+
143
+ def mime_type(path)
144
+ case File.extname(path)
145
+ when '.html' then 'text/html'
146
+ when '.css' then 'text/css'
147
+ when '.js' then 'application/javascript'
148
+ when '.json' then 'application/json'
149
+ when '.png' then 'image/png'
150
+ when '.jpg', '.jpeg' then 'image/jpeg'
151
+ when '.gif' then 'image/gif'
152
+ when '.svg' then 'image/svg+xml'
153
+ else 'text/plain'
154
+ end
155
+ end
156
+ end
157
+
158
+ # Live reload middleware
159
+ class LiveReloadMiddleware
160
+ RELOAD_SCRIPT = <<~JS
161
+ <script>
162
+ (function() {
163
+ let lastCheck = Date.now();
164
+ setInterval(function() {
165
+ fetch('/__rhes_reload_check')
166
+ .then(r => r.json())
167
+ .then(data => {
168
+ if (data.lastBuild > lastCheck) {
169
+ console.log('Jackdaw: Reloading page...');
170
+ location.reload();
171
+ }
172
+ lastCheck = Date.now();
173
+ })
174
+ .catch(() => {});
175
+ }, 1000);
176
+ })();
177
+ </script>
178
+ JS
179
+
180
+ def initialize(app, server)
181
+ @app = app
182
+ @server = server
183
+ @last_build = Time.now
184
+ end
185
+
186
+ def call(env)
187
+ # Handle reload check endpoint
188
+ if env['PATH_INFO'] == '/__rhes_reload_check'
189
+ return [
190
+ 200,
191
+ { 'Content-Type' => 'application/json' },
192
+ [JSON.generate({ lastBuild: @last_build.to_f })]
193
+ ]
194
+ end
195
+
196
+ status, headers, response = @app.call(env)
197
+
198
+ # Inject reload script into HTML responses
199
+ if headers['Content-Type']&.include?('text/html')
200
+ body = ''
201
+ response.each { |part| body << part }
202
+
203
+ if body.include?('</body>')
204
+ body = body.sub('</body>', "#{RELOAD_SCRIPT}</body>")
205
+ @last_build = Time.now
206
+ headers['Content-Length'] = body.bytesize.to_s
207
+ response = [body]
208
+ end
209
+ end
210
+
211
+ [status, headers, response]
212
+ end
213
+ end
214
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jackdaw
4
+ # Generates sitemap.xml for SEO
5
+ class SitemapGenerator
6
+ attr_reader :project, :scanner
7
+
8
+ def initialize(project)
9
+ @project = project
10
+ @scanner = Scanner.new(project)
11
+ end
12
+
13
+ # Generate sitemap.xml
14
+ def generate
15
+ urls = all_content_urls
16
+
17
+ sitemap = <<~SITEMAP
18
+ <?xml version="1.0" encoding="UTF-8"?>
19
+ <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
20
+ #{urls.map { |url| url_entry(url) }.join("\n")}
21
+ </urlset>
22
+ SITEMAP
23
+
24
+ File.write(File.join(project.output_dir, 'sitemap.xml'), sitemap)
25
+ end
26
+
27
+ private
28
+
29
+ def all_content_urls
30
+ scanner.content_files.map do |file|
31
+ {
32
+ path: file.output_path,
33
+ date: file.date,
34
+ type: file.type
35
+ }
36
+ end
37
+ end
38
+
39
+ def url_entry(url)
40
+ priority = calculate_priority(url[:type], url[:path])
41
+ changefreq = calculate_changefreq(url[:type])
42
+
43
+ <<~ENTRY.strip
44
+ <url>
45
+ <loc>#{site_url}/#{url[:path]}</loc>
46
+ <lastmod>#{url[:date].to_time.utc.iso8601}</lastmod>
47
+ <changefreq>#{changefreq}</changefreq>
48
+ <priority>#{priority}</priority>
49
+ </url>
50
+ ENTRY
51
+ end
52
+
53
+ def calculate_priority(type, path)
54
+ return '1.0' if path == 'index.html'
55
+ return '0.8' if type == 'page'
56
+ return '0.6' if %w[blog post article news].include?(type)
57
+
58
+ '0.5'
59
+ end
60
+
61
+ def calculate_changefreq(type)
62
+ return 'daily' if %w[blog post article news].include?(type)
63
+ return 'weekly' if type == 'page'
64
+
65
+ 'monthly'
66
+ end
67
+
68
+ def site_url
69
+ ENV.fetch('SITE_URL', 'http://localhost:4000')
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jackdaw
4
+ VERSION = '1.0.0'
5
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jackdaw
4
+ # Watches for file changes and triggers callbacks
5
+ class Watcher
6
+ attr_reader :project, :listener
7
+
8
+ def initialize(project)
9
+ @project = project
10
+ @callbacks = []
11
+ end
12
+
13
+ # Register a callback to be called on file changes
14
+ def on_change(&block)
15
+ @callbacks << block
16
+ end
17
+
18
+ # Start watching
19
+ def start
20
+ @listener = Listen.to(project.site_dir) do |modified, added, removed|
21
+ notify_callbacks(modified, added, removed)
22
+ end
23
+
24
+ listener.start
25
+ end
26
+
27
+ # Stop watching
28
+ def stop
29
+ listener&.stop
30
+ end
31
+
32
+ private
33
+
34
+ def notify_callbacks(modified, added, removed)
35
+ @callbacks.each do |callback|
36
+ callback.call(modified: modified, added: added, removed: removed)
37
+ end
38
+ end
39
+ end
40
+ end