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,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('&', '&amp;')
107
+ .gsub('<', '&lt;')
108
+ .gsub('>', '&gt;')
109
+ .gsub('"', '&quot;')
110
+ .gsub("'", '&apos;')
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