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.
- checksums.yaml +7 -0
- data/.rubocop.yml +32 -0
- data/CHANGELOG.md +82 -0
- data/README.md +467 -0
- data/Rakefile +8 -0
- data/TODO.md +167 -0
- data/benchmark.rb +235 -0
- data/exe/jackdaw +6 -0
- data/lib/jackdaw/builder.rb +206 -0
- data/lib/jackdaw/cli.rb +49 -0
- data/lib/jackdaw/cli_helpers.rb +33 -0
- data/lib/jackdaw/commands/build.rb +66 -0
- data/lib/jackdaw/commands/create.rb +111 -0
- data/lib/jackdaw/commands/new.rb +162 -0
- data/lib/jackdaw/commands/serve.rb +28 -0
- data/lib/jackdaw/commands/template.rb +60 -0
- data/lib/jackdaw/feed_generator.rb +113 -0
- data/lib/jackdaw/file_types.rb +171 -0
- data/lib/jackdaw/project.rb +80 -0
- data/lib/jackdaw/renderer.rb +154 -0
- data/lib/jackdaw/scanner.rb +44 -0
- data/lib/jackdaw/seo_helpers.rb +65 -0
- data/lib/jackdaw/server.rb +214 -0
- data/lib/jackdaw/sitemap_generator.rb +72 -0
- data/lib/jackdaw/version.rb +5 -0
- data/lib/jackdaw/watcher.rb +40 -0
- data/lib/jackdaw.rb +54 -0
- data/sig/jackdaw.rbs +4 -0
- metadata +185 -0
|
@@ -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('&', '&')
|
|
59
|
+
.gsub('<', '<')
|
|
60
|
+
.gsub('>', '>')
|
|
61
|
+
.gsub('"', '"')
|
|
62
|
+
.gsub("'", ''')
|
|
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,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
|