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,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Jackdaw
|
|
4
|
+
module Commands
|
|
5
|
+
# Build command implementation
|
|
6
|
+
class Build
|
|
7
|
+
include CLIHelpers
|
|
8
|
+
|
|
9
|
+
def initialize(project, options)
|
|
10
|
+
@project = project
|
|
11
|
+
@options = options
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def execute
|
|
15
|
+
return handle_missing_project unless @project.exists?
|
|
16
|
+
|
|
17
|
+
header('🚀 Building your site...')
|
|
18
|
+
info('Cleaning output directory...') if @options[:clean]
|
|
19
|
+
|
|
20
|
+
# Build
|
|
21
|
+
builder = Builder.new(@project, @options)
|
|
22
|
+
stats = builder.build
|
|
23
|
+
|
|
24
|
+
# Show results
|
|
25
|
+
puts ''
|
|
26
|
+
display_results(stats)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def handle_missing_project
|
|
32
|
+
puts colorize('✗ No site directory found. Run this command from a .site directory.', :yellow)
|
|
33
|
+
exit 1
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def display_results(stats)
|
|
37
|
+
if stats.success?
|
|
38
|
+
show_success(stats)
|
|
39
|
+
else
|
|
40
|
+
show_errors(stats)
|
|
41
|
+
exit 1
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def show_success(stats)
|
|
46
|
+
success("Built #{colorize(stats.files_built.to_s,
|
|
47
|
+
:cyan)} pages in #{colorize(format('%.2fs', stats.total_time), :cyan)}")
|
|
48
|
+
|
|
49
|
+
info("Skipped #{stats.files_skipped} unchanged files") if stats.files_skipped.positive?
|
|
50
|
+
info("Copied #{stats.assets_copied} assets") if stats.assets_copied.positive?
|
|
51
|
+
|
|
52
|
+
return unless stats.assets_skipped.positive? && @options[:verbose]
|
|
53
|
+
|
|
54
|
+
info("Skipped #{stats.assets_skipped} unchanged assets")
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def show_errors(stats)
|
|
58
|
+
puts colorize("✗ Build failed with #{stats.errors.length} errors:", :yellow)
|
|
59
|
+
stats.errors.each do |error|
|
|
60
|
+
puts " #{colorize('→', :yellow)} #{error.message}"
|
|
61
|
+
puts " #{error.backtrace.first}" if @options[:verbose] && error.backtrace
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Jackdaw
|
|
4
|
+
module Commands
|
|
5
|
+
# Create command implementation
|
|
6
|
+
class Create
|
|
7
|
+
include CLIHelpers
|
|
8
|
+
|
|
9
|
+
DATED_TYPES = %w[blog post article news].freeze
|
|
10
|
+
|
|
11
|
+
def initialize(project, template, name, options)
|
|
12
|
+
@project = project
|
|
13
|
+
@template = template
|
|
14
|
+
@name = name
|
|
15
|
+
@options = options
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def execute
|
|
19
|
+
unless @project.exists?
|
|
20
|
+
puts colorize('✗ No site directory found. Run this command from a .site directory.', :yellow)
|
|
21
|
+
exit 1
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
validate_template!
|
|
25
|
+
create_content_file
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def validate_template!
|
|
31
|
+
template_file = File.join(@project.templates_dir, "#{@template}.html.erb")
|
|
32
|
+
return if File.exist?(template_file)
|
|
33
|
+
|
|
34
|
+
puts colorize("✗ Template '#{@template}' not found", :yellow)
|
|
35
|
+
puts " Run #{colorize('jackdaw template list', :cyan)} to see available templates"
|
|
36
|
+
exit 1
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def create_content_file
|
|
40
|
+
filename = build_filename
|
|
41
|
+
output_path = build_output_path(filename)
|
|
42
|
+
|
|
43
|
+
check_file_exists!(output_path)
|
|
44
|
+
ensure_directory_exists!(output_path)
|
|
45
|
+
write_content_file!(output_path)
|
|
46
|
+
show_success_message(output_path)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def build_filename
|
|
50
|
+
slug = generate_slug
|
|
51
|
+
add_date_prefix? ? "#{date_prefix}-#{slug}.#{@template}.md" : "#{slug}.#{@template}.md"
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def generate_slug
|
|
55
|
+
path_parts = @name.split('/')
|
|
56
|
+
@filename_part = path_parts.pop
|
|
57
|
+
@subdir = path_parts.join('/')
|
|
58
|
+
|
|
59
|
+
@filename_part.downcase.gsub(/[^a-z0-9]+/, '-').gsub(/^-|-$/, '')
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def add_date_prefix?
|
|
63
|
+
if @options[:no_date]
|
|
64
|
+
false
|
|
65
|
+
elsif @options[:dated]
|
|
66
|
+
true
|
|
67
|
+
else
|
|
68
|
+
DATED_TYPES.include?(@template)
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def date_prefix
|
|
73
|
+
Time.now.strftime('%Y-%m-%d')
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def build_output_path(filename)
|
|
77
|
+
output_dir = @subdir.empty? ? @project.src_dir : File.join(@project.src_dir, @subdir)
|
|
78
|
+
File.join(output_dir, filename)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def check_file_exists!(output_path)
|
|
82
|
+
return unless File.exist?(output_path)
|
|
83
|
+
|
|
84
|
+
puts colorize("✗ File already exists: #{output_path}", :yellow)
|
|
85
|
+
exit 1
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def ensure_directory_exists!(output_path)
|
|
89
|
+
FileUtils.mkdir_p(File.dirname(output_path))
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def write_content_file!(output_path)
|
|
93
|
+
title = @name.split('/').last
|
|
94
|
+
content = <<~MARKDOWN
|
|
95
|
+
# #{title}
|
|
96
|
+
|
|
97
|
+
Your content goes here. Edit this file to create your #{@template}.
|
|
98
|
+
MARKDOWN
|
|
99
|
+
|
|
100
|
+
File.write(output_path, content)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def show_success_message(output_path)
|
|
104
|
+
relative_path = output_path.sub("#{Dir.pwd}/", '')
|
|
105
|
+
puts ''
|
|
106
|
+
success("Created #{colorize(@template, :cyan)}: #{colorize(relative_path, :green)}")
|
|
107
|
+
info("Edit: #{colorize(relative_path, :cyan)}")
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Jackdaw
|
|
4
|
+
module Commands
|
|
5
|
+
# New command implementation for creating new site projects
|
|
6
|
+
class New
|
|
7
|
+
include CLIHelpers
|
|
8
|
+
|
|
9
|
+
def initialize(name)
|
|
10
|
+
@name = name
|
|
11
|
+
@site_dir = "#{name}.site"
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def execute
|
|
15
|
+
check_directory_exists!
|
|
16
|
+
|
|
17
|
+
header("✨ Creating new site: #{@name}")
|
|
18
|
+
create_project_structure
|
|
19
|
+
show_next_steps
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
def check_directory_exists!
|
|
25
|
+
return unless Dir.exist?(@site_dir)
|
|
26
|
+
|
|
27
|
+
puts colorize("✗ Directory #{@site_dir} already exists", :yellow)
|
|
28
|
+
exit 1
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def create_project_structure
|
|
32
|
+
info('Creating directory structure...')
|
|
33
|
+
project = Project.new(@site_dir)
|
|
34
|
+
project.create!
|
|
35
|
+
|
|
36
|
+
create_starter_templates(project)
|
|
37
|
+
create_example_content(project)
|
|
38
|
+
create_gitignore(project)
|
|
39
|
+
|
|
40
|
+
success("Site created at #{colorize(@site_dir, :cyan)}")
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def create_starter_templates(project)
|
|
44
|
+
create_layout_template(project)
|
|
45
|
+
create_nav_partial(project)
|
|
46
|
+
create_page_template(project)
|
|
47
|
+
create_blog_template(project)
|
|
48
|
+
|
|
49
|
+
success('Created starter templates')
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def create_layout_template(project)
|
|
53
|
+
layout_template = File.join(project.templates_dir, 'layout.html.erb')
|
|
54
|
+
File.write(layout_template, <<~ERB)
|
|
55
|
+
<!DOCTYPE html>
|
|
56
|
+
<html lang="en">
|
|
57
|
+
<head>
|
|
58
|
+
<meta charset="UTF-8">
|
|
59
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
60
|
+
<title><%= title %> - <%= site_name %></title>
|
|
61
|
+
<style>
|
|
62
|
+
body { max-width: 800px; margin: 0 auto; padding: 2rem; font-family: system-ui; line-height: 1.6; }
|
|
63
|
+
nav { margin-bottom: 2rem; padding-bottom: 1rem; border-bottom: 1px solid #ddd; }
|
|
64
|
+
nav a { margin-right: 1rem; text-decoration: none; }
|
|
65
|
+
</style>
|
|
66
|
+
</head>
|
|
67
|
+
<body>
|
|
68
|
+
<%= render 'nav' %>
|
|
69
|
+
<%= content %>
|
|
70
|
+
</body>
|
|
71
|
+
</html>
|
|
72
|
+
ERB
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def create_nav_partial(project)
|
|
76
|
+
nav_partial = File.join(project.templates_dir, '_nav.html.erb')
|
|
77
|
+
File.write(nav_partial, <<~ERB)
|
|
78
|
+
<nav>
|
|
79
|
+
<a href="/">Home</a>
|
|
80
|
+
<a href="/blog">Blog</a>
|
|
81
|
+
</nav>
|
|
82
|
+
ERB
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def create_page_template(project)
|
|
86
|
+
page_template = File.join(project.templates_dir, 'page.html.erb')
|
|
87
|
+
File.write(page_template, <<~ERB)
|
|
88
|
+
<main>
|
|
89
|
+
<%= content %>
|
|
90
|
+
</main>
|
|
91
|
+
ERB
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def create_blog_template(project)
|
|
95
|
+
blog_template = File.join(project.templates_dir, 'blog.html.erb')
|
|
96
|
+
File.write(blog_template, <<~ERB)
|
|
97
|
+
<article>
|
|
98
|
+
<header>
|
|
99
|
+
<h1><%= title %></h1>
|
|
100
|
+
<time datetime="<%= date %>"><%= date.strftime('%B %d, %Y') %></time>
|
|
101
|
+
</header>
|
|
102
|
+
<%= content %>
|
|
103
|
+
</article>
|
|
104
|
+
ERB
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def create_example_content(project)
|
|
108
|
+
create_index_page(project)
|
|
109
|
+
create_first_blog_post(project)
|
|
110
|
+
|
|
111
|
+
success('Created example content')
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def create_index_page(project)
|
|
115
|
+
index_page = File.join(project.src_dir, 'index.page.md')
|
|
116
|
+
File.write(index_page, <<~MD)
|
|
117
|
+
# Welcome to Jackdaw
|
|
118
|
+
|
|
119
|
+
This is your new static site, built with lightning-fast Jackdaw.
|
|
120
|
+
|
|
121
|
+
## Getting Started
|
|
122
|
+
|
|
123
|
+
Edit this file at `site/src/index.page.md` and run `jackdaw build` to see your changes.
|
|
124
|
+
MD
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def create_first_blog_post(project)
|
|
128
|
+
blog_dir = File.join(project.src_dir, 'blog')
|
|
129
|
+
FileUtils.mkdir_p(blog_dir)
|
|
130
|
+
|
|
131
|
+
first_post = File.join(blog_dir, '2026-01-06-hello-world.blog.md')
|
|
132
|
+
File.write(first_post, <<~MD)
|
|
133
|
+
# Hello World
|
|
134
|
+
|
|
135
|
+
Welcome to your first blog post! This post demonstrates:
|
|
136
|
+
|
|
137
|
+
- Automatic date extraction from filename
|
|
138
|
+
- Title extraction from the first H1
|
|
139
|
+
- Folder structure preservation
|
|
140
|
+
|
|
141
|
+
Edit this file at `site/src/blog/2026-01-06-hello-world.blog.md`
|
|
142
|
+
MD
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def create_gitignore(project)
|
|
146
|
+
gitignore = File.join(project.root, '.gitignore')
|
|
147
|
+
File.write(gitignore, <<~IGNORE)
|
|
148
|
+
public/
|
|
149
|
+
.DS_Store
|
|
150
|
+
IGNORE
|
|
151
|
+
success('Created .gitignore')
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def show_next_steps
|
|
155
|
+
puts "\n#{colorize('Next steps:', :bold)}"
|
|
156
|
+
info("cd #{@site_dir}")
|
|
157
|
+
info('jackdaw build')
|
|
158
|
+
info('jackdaw serve')
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Jackdaw
|
|
4
|
+
module Commands
|
|
5
|
+
# Serve command implementation
|
|
6
|
+
class Serve
|
|
7
|
+
include CLIHelpers
|
|
8
|
+
|
|
9
|
+
def initialize(project, options)
|
|
10
|
+
@project = project
|
|
11
|
+
@options = options
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def execute
|
|
15
|
+
unless @project.exists?
|
|
16
|
+
puts colorize('✗ No site directory found. Run this command from a .site directory.', :yellow)
|
|
17
|
+
exit 1
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
server = Server.new(@project, @options)
|
|
21
|
+
server.start
|
|
22
|
+
rescue Interrupt
|
|
23
|
+
puts "\n\n#{colorize('👋 Server stopped', :magenta)}"
|
|
24
|
+
exit 0
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Jackdaw
|
|
4
|
+
module Commands
|
|
5
|
+
# Template subcommands
|
|
6
|
+
class Template < Thor
|
|
7
|
+
include CLIHelpers
|
|
8
|
+
|
|
9
|
+
def self.exit_on_failure?
|
|
10
|
+
true
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
desc 'list', 'List available templates'
|
|
14
|
+
def list
|
|
15
|
+
project = Project.new
|
|
16
|
+
|
|
17
|
+
unless project.exists?
|
|
18
|
+
puts colorize('✗ No site directory found. Run this command from a .site directory.', :yellow)
|
|
19
|
+
exit 1
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
template_files = find_template_files(project)
|
|
23
|
+
|
|
24
|
+
if template_files.empty?
|
|
25
|
+
puts colorize('No templates found in site/templates/', :yellow)
|
|
26
|
+
exit 0
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
display_templates(template_files)
|
|
30
|
+
show_usage_examples
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def find_template_files(project)
|
|
36
|
+
Dir.glob(File.join(project.templates_dir, '*.html.erb'))
|
|
37
|
+
.map { |f| File.basename(f, '.html.erb') }
|
|
38
|
+
.reject { |name| name == 'layout' || name.start_with?('_') }
|
|
39
|
+
.sort
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def display_templates(template_files)
|
|
43
|
+
puts "\n#{colorize('Available templates:', :bold)}"
|
|
44
|
+
template_files.each do |template|
|
|
45
|
+
dated_types = %w[blog post article news]
|
|
46
|
+
dated_indicator = dated_types.include?(template) ? colorize(' (dated)', :cyan) : ''
|
|
47
|
+
puts " #{colorize('→', :green)} #{colorize(template, :cyan)}#{dated_indicator}"
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def show_usage_examples
|
|
52
|
+
puts "\n#{colorize('Usage:', :bold)}"
|
|
53
|
+
puts ' jackdaw create <template> <name>'
|
|
54
|
+
puts "\n#{colorize('Examples:', :bold)}"
|
|
55
|
+
puts ' jackdaw create page "About Us"'
|
|
56
|
+
puts ' jackdaw create blog "My First Post"'
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Jackdaw
|
|
4
|
+
# Generates RSS and Atom feeds for blog posts
|
|
5
|
+
class FeedGenerator
|
|
6
|
+
attr_reader :project, :scanner
|
|
7
|
+
|
|
8
|
+
def initialize(project)
|
|
9
|
+
@project = project
|
|
10
|
+
@scanner = Scanner.new(project)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# Generate RSS feed for blog posts
|
|
14
|
+
def generate_rss
|
|
15
|
+
posts = blog_posts.take(20) # Most recent 20 posts
|
|
16
|
+
|
|
17
|
+
rss = <<~RSS
|
|
18
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
19
|
+
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
|
|
20
|
+
<channel>
|
|
21
|
+
<title>#{site_name}</title>
|
|
22
|
+
<link>#{site_url}</link>
|
|
23
|
+
<description>#{site_description}</description>
|
|
24
|
+
<language>en</language>
|
|
25
|
+
<atom:link href="#{site_url}/feed.xml" rel="self" type="application/rss+xml" />
|
|
26
|
+
#{posts.map { |post| rss_item(post) }.join("\n")}
|
|
27
|
+
</channel>
|
|
28
|
+
</rss>
|
|
29
|
+
RSS
|
|
30
|
+
|
|
31
|
+
File.write(File.join(project.output_dir, 'feed.xml'), rss)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Generate Atom feed for blog posts
|
|
35
|
+
def generate_atom
|
|
36
|
+
posts = blog_posts.take(20)
|
|
37
|
+
updated = posts.first&.date&.to_time&.utc&.iso8601 || Time.now.utc.iso8601
|
|
38
|
+
|
|
39
|
+
atom = <<~ATOM
|
|
40
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
41
|
+
<feed xmlns="http://www.w3.org/2005/Atom">
|
|
42
|
+
<title>#{site_name}</title>
|
|
43
|
+
<link href="#{site_url}" />
|
|
44
|
+
<link href="#{site_url}/atom.xml" rel="self" />
|
|
45
|
+
<updated>#{updated}</updated>
|
|
46
|
+
<id>#{site_url}/</id>
|
|
47
|
+
<author>
|
|
48
|
+
<name>#{site_name}</name>
|
|
49
|
+
</author>
|
|
50
|
+
#{posts.map { |post| atom_entry(post) }.join("\n")}
|
|
51
|
+
</feed>
|
|
52
|
+
ATOM
|
|
53
|
+
|
|
54
|
+
File.write(File.join(project.output_dir, 'atom.xml'), atom)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
private
|
|
58
|
+
|
|
59
|
+
def blog_posts
|
|
60
|
+
@blog_posts ||= scanner.content_files
|
|
61
|
+
.select { |f| %w[blog post article news].include?(f.type) }
|
|
62
|
+
.sort_by(&:date)
|
|
63
|
+
.reverse
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def rss_item(post)
|
|
67
|
+
<<~ITEM.strip
|
|
68
|
+
<item>
|
|
69
|
+
<title>#{escape_xml(post.title)}</title>
|
|
70
|
+
<link>#{site_url}/#{post.output_path}</link>
|
|
71
|
+
<guid>#{site_url}/#{post.output_path}</guid>
|
|
72
|
+
<pubDate>#{post.date.to_time.utc.rfc822}</pubDate>
|
|
73
|
+
<description>#{escape_xml(post.excerpt)}</description>
|
|
74
|
+
</item>
|
|
75
|
+
ITEM
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def atom_entry(post)
|
|
79
|
+
<<~ENTRY.strip
|
|
80
|
+
<entry>
|
|
81
|
+
<title>#{escape_xml(post.title)}</title>
|
|
82
|
+
<link href="#{site_url}/#{post.output_path}" />
|
|
83
|
+
<id>#{site_url}/#{post.output_path}</id>
|
|
84
|
+
<updated>#{post.date.to_time.utc.iso8601}</updated>
|
|
85
|
+
<summary>#{escape_xml(post.excerpt)}</summary>
|
|
86
|
+
</entry>
|
|
87
|
+
ENTRY
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def site_name
|
|
91
|
+
project_name = File.basename(project.root)
|
|
92
|
+
project_name.sub(/\.site$/, '').tr('-', ' ').split.map(&:capitalize).join(' ')
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def site_url
|
|
96
|
+
# Default to localhost for development, can be overridden
|
|
97
|
+
ENV.fetch('SITE_URL', 'http://localhost:4000')
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def site_description
|
|
101
|
+
"Latest posts from #{site_name}"
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def escape_xml(text)
|
|
105
|
+
text.to_s
|
|
106
|
+
.gsub('&', '&')
|
|
107
|
+
.gsub('<', '<')
|
|
108
|
+
.gsub('>', '>')
|
|
109
|
+
.gsub('"', '"')
|
|
110
|
+
.gsub("'", ''')
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Jackdaw
|
|
4
|
+
# Base class for all file types
|
|
5
|
+
class BaseFile
|
|
6
|
+
attr_reader :path, :project
|
|
7
|
+
|
|
8
|
+
def initialize(path, project)
|
|
9
|
+
@path = File.expand_path(path)
|
|
10
|
+
@project = project
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def basename
|
|
14
|
+
File.basename(path)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def mtime
|
|
18
|
+
File.mtime(path)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def relative_path
|
|
22
|
+
path.sub("#{base_dir}/", '')
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def base_dir
|
|
26
|
+
raise NotImplementedError
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Represents a content file (*.*.md)
|
|
31
|
+
class ContentFile < BaseFile
|
|
32
|
+
def base_dir
|
|
33
|
+
project.src_dir
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Extract type from filename (e.g., "hello.blog.md" -> "blog")
|
|
37
|
+
def type
|
|
38
|
+
@type ||= begin
|
|
39
|
+
parts = basename.split('.')
|
|
40
|
+
parts[-2] if parts.length >= 3
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Extract name from filename (e.g., "2026-01-06-hello.blog.md" -> "hello")
|
|
45
|
+
def name
|
|
46
|
+
@name ||= begin
|
|
47
|
+
base = basename.sub(/\.#{type}\.md$/, '')
|
|
48
|
+
# Remove date prefix if present
|
|
49
|
+
base.sub(/^\d{4}-\d{2}-\d{2}-/, '')
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Extract date from filename or fall back to mtime
|
|
54
|
+
def date
|
|
55
|
+
@date ||= if basename =~ /^(\d{4}-\d{2}-\d{2})-/
|
|
56
|
+
Date.parse(::Regexp.last_match(1))
|
|
57
|
+
else
|
|
58
|
+
Date.parse(mtime.strftime('%Y-%m-%d'))
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Slug for URL (e.g., "hello_world" or "2026-01-06-hello_world")
|
|
63
|
+
def slug
|
|
64
|
+
@slug ||= name.gsub('_', '-')
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Output path relative to public/
|
|
68
|
+
def output_path
|
|
69
|
+
dir = File.dirname(relative_path)
|
|
70
|
+
filename = "#{name}.html"
|
|
71
|
+
dir == '.' ? filename : File.join(dir, filename)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Full output path
|
|
75
|
+
def output_file
|
|
76
|
+
File.join(project.output_dir, output_path)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Read raw content
|
|
80
|
+
def content
|
|
81
|
+
@content ||= File.read(path)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Extract title from first H1
|
|
85
|
+
def title
|
|
86
|
+
@title ||= begin
|
|
87
|
+
match = content.match(/^#\s+(.+)$/)
|
|
88
|
+
match ? match[1].strip : name.gsub(/[-_]/, ' ').capitalize
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Extract excerpt (first paragraph or 150 words)
|
|
93
|
+
def excerpt
|
|
94
|
+
@excerpt ||= begin
|
|
95
|
+
# Remove title (first H1)
|
|
96
|
+
text = content.sub(/^#\s+.+$/, '').strip
|
|
97
|
+
|
|
98
|
+
# Get first paragraph or first 150 words
|
|
99
|
+
first_para = text.split("\n\n").first
|
|
100
|
+
words = first_para.to_s.split[0...150]
|
|
101
|
+
excerpt_text = words.join(' ')
|
|
102
|
+
|
|
103
|
+
# Add ellipsis if truncated
|
|
104
|
+
excerpt_text += '...' if words.length == 150
|
|
105
|
+
excerpt_text
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Calculate reading time (words per minute = 200)
|
|
110
|
+
def reading_time
|
|
111
|
+
@reading_time ||= begin
|
|
112
|
+
word_count = content.split.length
|
|
113
|
+
minutes = (word_count / 200.0).ceil
|
|
114
|
+
[minutes, 1].max
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Metadata hash
|
|
119
|
+
def metadata
|
|
120
|
+
{
|
|
121
|
+
title: title,
|
|
122
|
+
date: date,
|
|
123
|
+
slug: slug,
|
|
124
|
+
type: type,
|
|
125
|
+
path: output_path,
|
|
126
|
+
excerpt: excerpt,
|
|
127
|
+
reading_time: reading_time
|
|
128
|
+
}
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Represents a template file (*.html.erb)
|
|
133
|
+
class TemplateFile < BaseFile
|
|
134
|
+
def base_dir
|
|
135
|
+
project.templates_dir
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# Extract template type (e.g., "blog.html.erb" -> "blog")
|
|
139
|
+
def type
|
|
140
|
+
@type ||= basename.sub(/\.html\.erb$/, '')
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Read template content
|
|
144
|
+
def content
|
|
145
|
+
@content ||= File.read(path)
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# Represents an asset file
|
|
150
|
+
class AssetFile < BaseFile
|
|
151
|
+
def base_dir
|
|
152
|
+
project.assets_dir
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# Output path relative to public/
|
|
156
|
+
def output_path
|
|
157
|
+
relative_path
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# Full output path
|
|
161
|
+
def output_file
|
|
162
|
+
File.join(project.output_dir, output_path)
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# Copy asset to output
|
|
166
|
+
def copy!
|
|
167
|
+
FileUtils.mkdir_p(File.dirname(output_file))
|
|
168
|
+
FileUtils.cp(path, output_file)
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
end
|