wytch 0.1.0 → 0.3.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.
Files changed (67) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +17 -0
  3. data/README.md +23 -1
  4. data/exe/wytch +6 -0
  5. data/lib/wytch/builder.rb +75 -0
  6. data/lib/wytch/cli.rb +127 -0
  7. data/lib/wytch/content_loader.rb +54 -0
  8. data/lib/wytch/once.rb +40 -0
  9. data/lib/wytch/page.rb +136 -0
  10. data/lib/wytch/reload_coordinator.rb +68 -0
  11. data/lib/wytch/server.rb +93 -0
  12. data/lib/wytch/site.rb +97 -0
  13. data/lib/wytch/site_code_loader_middleware.rb +37 -0
  14. data/lib/wytch/templates/Gemfile.tt +12 -0
  15. data/lib/wytch/templates/config.rb.tt +6 -0
  16. data/lib/wytch/templates/content/feed.rb.tt +4 -0
  17. data/lib/wytch/templates/content/index.rb.tt +6 -0
  18. data/lib/wytch/templates/content/posts/hello-world.rb.tt +12 -0
  19. data/lib/wytch/templates/content/sitemap.rb.tt +4 -0
  20. data/lib/wytch/templates/frontend/Main.res.tt +2 -0
  21. data/lib/wytch/templates/frontend/main.css.tt +1 -0
  22. data/lib/wytch/templates/gitignore.tt +4 -0
  23. data/lib/wytch/templates/package.json.tt +19 -0
  24. data/lib/wytch/templates/public/robots.txt.tt +2 -0
  25. data/lib/wytch/templates/rescript.json.tt +18 -0
  26. data/lib/wytch/templates/src/site/feed_helper.rb.tt +17 -0
  27. data/lib/wytch/templates/src/site/feed_view.rb.tt +42 -0
  28. data/lib/wytch/templates/src/site/home_view.rb.tt +29 -0
  29. data/lib/wytch/templates/src/site/layout.rb.tt +34 -0
  30. data/lib/wytch/templates/src/site/page.rb.tt +9 -0
  31. data/lib/wytch/templates/src/site/post_helpers.rb.tt +18 -0
  32. data/lib/wytch/templates/src/site/post_view.rb.tt +21 -0
  33. data/lib/wytch/templates/src/site/sitemap_helper.rb.tt +17 -0
  34. data/lib/wytch/templates/src/site/sitemap_view.rb.tt +27 -0
  35. data/lib/wytch/templates/vite.config.js.tt +26 -0
  36. data/lib/wytch/version.rb +2 -1
  37. data/lib/wytch.rb +52 -2
  38. data/website/.gitignore +4 -0
  39. data/website/Gemfile +8 -0
  40. data/website/Gemfile.lock +63 -0
  41. data/website/assets/Main.res +2 -0
  42. data/website/assets/main.css +1 -0
  43. data/website/bin/generate_api_docs +44 -0
  44. data/website/config.rb +6 -0
  45. data/website/content/api/wytch/builder.rb +6 -0
  46. data/website/content/api/wytch/cli.rb +6 -0
  47. data/website/content/api/wytch/contentloader.rb +6 -0
  48. data/website/content/api/wytch/error.rb +6 -0
  49. data/website/content/api/wytch/once.rb +6 -0
  50. data/website/content/api/wytch/page.rb +6 -0
  51. data/website/content/api/wytch/reloadcoordinator.rb +6 -0
  52. data/website/content/api/wytch/server.rb +6 -0
  53. data/website/content/api/wytch/site.rb +6 -0
  54. data/website/content/api/wytch/sitecodeloadermiddleware.rb +6 -0
  55. data/website/content/index.rb +6 -0
  56. data/website/content/sitemap.rb +4 -0
  57. data/website/package.json +19 -0
  58. data/website/public/robots.txt +2 -0
  59. data/website/rescript.json +18 -0
  60. data/website/src/wytch_site/api_class_view.rb +90 -0
  61. data/website/src/wytch_site/home_view.rb +33 -0
  62. data/website/src/wytch_site/layout.rb +34 -0
  63. data/website/src/wytch_site/page.rb +18 -0
  64. data/website/src/wytch_site/sitemap_helper.rb +17 -0
  65. data/website/src/wytch_site/sitemap_view.rb +27 -0
  66. data/website/vite.config.js +26 -0
  67. metadata +164 -4
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 61bbb9530546971a617d753468cc6213d7fc11fc64c79242ac6ba81929b48cb1
4
- data.tar.gz: 59a823a9af8dc811e2deaf940eaae0b1f08c8cdb2a746fd4558d039c1de2623b
3
+ metadata.gz: 7aa017b3a2d6ed1b64cde7b4a73f312222d64a630a365ad71db68f4babc28ce5
4
+ data.tar.gz: fba837a626f3c4dfcc3525d03127ada6b62b30b367e31da4c5eef3721c7b264a
5
5
  SHA512:
6
- metadata.gz: bd3d5170c33314e16a993d3fbc6bda029f42a3d09d09ae1bec03c2dbfe3a2823c56acff5feeda33cb7f90367db1ac3b6433e48d05b4422b479af4c7e7b0963e0
7
- data.tar.gz: c9ead1e98b30442aabe805f98a77c95ab0d6e005c79633b03d0d0dcecf64fdfd0be3a7439a317e2fb049c89fc097ef2d68e736d58bceac0a3c6a6587611c79fd
6
+ metadata.gz: b70258ec6436a644dee4ecb637dc0bcad929428c7803d4272b7eef34b8bb0620b024c683a2a77f41e1235c05f5c1713b188a374bc7d050bb77a571bd924db7f0
7
+ data.tar.gz: 98a84928a3cf7bd5aff50e99225d6b944da43ca69a44c2bb94a10ee8bc5ba424365ca228378f2f2472757b25632f86b7b5bea0aaa458e075100e2b96f1f87985
data/CHANGELOG.md CHANGED
@@ -1,5 +1,22 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.3.0] - 2025-12-7
4
+
5
+ - Add YARD documentation to all classes and methods
6
+ - Add UTF-8 charset meta tag to generated site layouts
7
+ - Fix index page building to `build/index.html` instead of `build/index/index.html`
8
+ - Update CLI output to reflect single-command build process
9
+
10
+ ## [0.2.0] - 2025-12-7
11
+
12
+ - Add Vite integration for asset pipeline (CSS, ReScript)
13
+ - Add blog scaffolding with posts, sitemap, and Atom feed
14
+ - Add Layout component for consistent page structure
15
+ - `wytch build` now automatically runs `npm run build` for assets
16
+ - Add static file serving from `public/` directory
17
+ - Add hot-reloading for site code and content during development
18
+ - Add documentation (README usage guide)
19
+
3
20
  ## [0.1.0] - 2025-10-29
4
21
 
5
22
  - Initial release
data/README.md CHANGED
@@ -11,7 +11,29 @@ gem install wytch
11
11
 
12
12
  ## Usage
13
13
 
14
- FIXME
14
+ Generate a new site like this:
15
+
16
+ ```bash
17
+ wytch new my-site
18
+ cd my-site
19
+ bundle install
20
+ npm install
21
+ ```
22
+
23
+ Run the development servers with:
24
+
25
+ ```bash
26
+ npm run dev # Runs the vite dev server for assets
27
+ wytch server # Runes the Wytch dev server
28
+ ```
29
+
30
+ Then visit `http://localhost:6969` to see your site.
31
+
32
+ Once you are ready to build for production:
33
+
34
+ ```bash
35
+ wytch build # Builds assets and generates your static site in the build directory.
36
+ ```
15
37
 
16
38
  ## Development
17
39
 
data/exe/wytch ADDED
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "wytch"
5
+
6
+ Wytch::CLI.start(ARGV)
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+
5
+ module Wytch
6
+ # Builds the static site by rendering all pages to the output directory.
7
+ #
8
+ # The Builder is responsible for the production build process:
9
+ # 1. Loading site configuration and content
10
+ # 2. Rendering each page to HTML
11
+ # 3. Copying static files from public/
12
+ # 4. Integrating Vite-built assets
13
+ #
14
+ # @example Building via CLI
15
+ # $ wytch build
16
+ #
17
+ # @example Building programmatically
18
+ # Wytch::Builder.new.build
19
+ class Builder
20
+ # @return [String] the output directory for built files
21
+ OUTPUT_DIR = "build"
22
+
23
+ # Builds the entire site.
24
+ #
25
+ # Sets RACK_ENV to "production", loads the site configuration,
26
+ # renders all pages to HTML files, and copies static assets.
27
+ #
28
+ # @return [void]
29
+ def build
30
+ ENV["RACK_ENV"] = "production"
31
+ Site.load!
32
+
33
+ Wytch.site.site_code_loader.eager_load
34
+ Wytch.site.load_content
35
+
36
+ FileUtils.mkdir(OUTPUT_DIR) unless Dir.exist?(OUTPUT_DIR)
37
+
38
+ Wytch.site.pages.values.each do |page|
39
+ build_path = File.join(OUTPUT_DIR, page.build_path)
40
+
41
+ puts "Building #{page.path} → #{build_path}"
42
+
43
+ FileUtils.mkdir_p File.dirname(build_path)
44
+
45
+ File.write build_path, page.render
46
+ end
47
+
48
+ copy_public_files
49
+ copy_vite_assets
50
+ end
51
+
52
+ private
53
+
54
+ # Copies files from public/ to the output directory.
55
+ #
56
+ # @return [void]
57
+ def copy_public_files
58
+ return unless Dir.exist?("public")
59
+
60
+ FileUtils.cp_r "public/.", OUTPUT_DIR, verbose: true
61
+ end
62
+
63
+ # Reports on Vite assets in the output directory.
64
+ # Vite builds directly to build/assets, so no copying is needed.
65
+ #
66
+ # @return [void]
67
+ def copy_vite_assets
68
+ vite_output = File.join(OUTPUT_DIR, "assets")
69
+ return unless Dir.exist?(vite_output)
70
+
71
+ # Vite already builds to build/assets, so assets are already in place
72
+ puts "Vite assets ready at #{vite_output}"
73
+ end
74
+ end
75
+ end
data/lib/wytch/cli.rb ADDED
@@ -0,0 +1,127 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "thor"
4
+
5
+ module Wytch
6
+ # Command-line interface for Wytch.
7
+ #
8
+ # Provides commands for creating, developing, and building Wytch sites.
9
+ #
10
+ # @example
11
+ # $ wytch new my-site # Create a new site
12
+ # $ wytch server # Start development server
13
+ # $ wytch build # Build for production
14
+ class CLI < Thor
15
+ include Thor::Actions
16
+
17
+ # Returns the path to template files used by the generator.
18
+ #
19
+ # @return [String] path to templates directory
20
+ def self.source_root
21
+ File.expand_path("templates", __dir__)
22
+ end
23
+
24
+ desc "new NAME", "Create a new Wytch site"
25
+ method_option :local, type: :boolean, default: false, desc: "Use local Wytch gem for development"
26
+ # Creates a new Wytch site with the given name.
27
+ #
28
+ # Generates a complete site structure including:
29
+ # - Configuration files (Gemfile, config.rb, package.json, etc.)
30
+ # - Content directory with sample pages
31
+ # - Source directory with views and layouts
32
+ # - Asset pipeline setup (Vite, ReScript, Tailwind)
33
+ #
34
+ # @param name [String] the name/directory for the new site
35
+ # @return [void]
36
+ def new(name)
37
+ @local_wytch_path = File.expand_path("../..", __dir__) if options[:local]
38
+
39
+ # Extract site name and create module constant
40
+ @site_name = File.basename(name)
41
+ @site_module = classify(@site_name)
42
+
43
+ empty_directory(name)
44
+ template("Gemfile.tt", "#{name}/Gemfile")
45
+ template("config.rb.tt", "#{name}/config.rb")
46
+ template("gitignore.tt", "#{name}/.gitignore")
47
+
48
+ # Asset pipeline configuration
49
+ template("package.json.tt", "#{name}/package.json")
50
+ template("vite.config.js.tt", "#{name}/vite.config.js")
51
+ template("rescript.json.tt", "#{name}/rescript.json")
52
+
53
+ # Assets directory
54
+ empty_directory("#{name}/assets")
55
+ template("frontend/Main.res.tt", "#{name}/assets/Main.res")
56
+ template("frontend/main.css.tt", "#{name}/assets/main.css")
57
+
58
+ # Content directory
59
+ empty_directory("#{name}/content")
60
+ template("content/index.rb.tt", "#{name}/content/index.rb")
61
+ empty_directory("#{name}/content/posts")
62
+ template("content/posts/hello-world.rb.tt", "#{name}/content/posts/hello-world.rb")
63
+ template("content/sitemap.rb.tt", "#{name}/content/sitemap.rb")
64
+ template("content/feed.rb.tt", "#{name}/content/feed.rb")
65
+
66
+ # Src directory with namespaced code
67
+ empty_directory("#{name}/src")
68
+ empty_directory("#{name}/src/#{@site_name}")
69
+ template("src/site/page.rb.tt", "#{name}/src/#{@site_name}/page.rb")
70
+ template("src/site/layout.rb.tt", "#{name}/src/#{@site_name}/layout.rb")
71
+ template("src/site/home_view.rb.tt", "#{name}/src/#{@site_name}/home_view.rb")
72
+ template("src/site/post_view.rb.tt", "#{name}/src/#{@site_name}/post_view.rb")
73
+ template("src/site/post_helpers.rb.tt", "#{name}/src/#{@site_name}/post_helpers.rb")
74
+ template("src/site/sitemap_view.rb.tt", "#{name}/src/#{@site_name}/sitemap_view.rb")
75
+ template("src/site/sitemap_helper.rb.tt", "#{name}/src/#{@site_name}/sitemap_helper.rb")
76
+ template("src/site/feed_view.rb.tt", "#{name}/src/#{@site_name}/feed_view.rb")
77
+ template("src/site/feed_helper.rb.tt", "#{name}/src/#{@site_name}/feed_helper.rb")
78
+
79
+ # Public directory for static assets
80
+ empty_directory("#{name}/public")
81
+ template("public/robots.txt.tt", "#{name}/public/robots.txt")
82
+
83
+ say "Created new Wytch site in #{name}/", :green
84
+ say "\nNext steps:", :green
85
+ say " cd #{name}"
86
+ say " bundle install"
87
+ say " npm install"
88
+ say "\nTo start developing:"
89
+ say " npm run dev # Start Vite dev server (Terminal 1)"
90
+ say " wytch server # Start Wytch dev server (Terminal 2)"
91
+ say "\nTo build for production:"
92
+ say " wytch build # Build assets and static site to build/"
93
+ end
94
+
95
+ desc "server", "Start a development server"
96
+ method_option :port, type: :numeric, default: 6969, aliases: "-p", desc: "Port to run the server on"
97
+ method_option :host, type: :string, default: "localhost", aliases: "-h", desc: "Host to bind the server to"
98
+ # Starts the development server with hot reloading.
99
+ #
100
+ # @return [void]
101
+ def server
102
+ Server.new(options).start
103
+ end
104
+
105
+ desc "build", "Build the static site"
106
+ # Builds the site for production.
107
+ #
108
+ # First runs `npm run build` to compile assets with Vite,
109
+ # then renders all pages to static HTML in the build/ directory.
110
+ #
111
+ # @return [void]
112
+ def build
113
+ system("npm run build") || abort("Asset build failed")
114
+ Builder.new.build
115
+ end
116
+
117
+ private
118
+
119
+ # Converts a snake_case name to PascalCase.
120
+ #
121
+ # @param name [String] the name to classify
122
+ # @return [String] the PascalCase version
123
+ def classify(name)
124
+ name.split("_").map(&:capitalize).join
125
+ end
126
+ end
127
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wytch
4
+ # Discovers and loads content files into Page objects.
5
+ #
6
+ # The ContentLoader scans the content directory for Ruby files and
7
+ # instantiates Page objects for each one. It uses the configured
8
+ # page_class from the Site.
9
+ #
10
+ # @see Wytch::Site#content_dir
11
+ # @see Wytch::Site#page_class
12
+ class ContentLoader
13
+ # Loads all content files and returns a hash of pages.
14
+ #
15
+ # Scans the content directory recursively for .rb files,
16
+ # creates a Page instance for each, and returns them keyed by path.
17
+ #
18
+ # @return [Hash{String => Page}] pages keyed by their URL path
19
+ def load_content
20
+ pages = {}
21
+
22
+ Dir.glob(File.join("**", "*.rb"), base: content_dir).each do |file_path|
23
+ page = load_page(file_path)
24
+ pages[page.path] = page
25
+ end
26
+
27
+ pages
28
+ end
29
+
30
+ private
31
+
32
+ # Creates a Page instance from a content file.
33
+ #
34
+ # @param file_path [String] path to the content file, relative to content_dir
35
+ # @return [Page] the loaded page
36
+ def load_page(file_path)
37
+ page_class.new(file_path:)
38
+ end
39
+
40
+ # Returns the configured page class.
41
+ #
42
+ # @return [Class] the page class to instantiate
43
+ def page_class
44
+ Object.const_get(Wytch.site.page_class)
45
+ end
46
+
47
+ # Returns the content directory path.
48
+ #
49
+ # @return [String] the content directory
50
+ def content_dir
51
+ Wytch.site.content_dir
52
+ end
53
+ end
54
+ end
data/lib/wytch/once.rb ADDED
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wytch
4
+ # A thread-safe utility for executing a block exactly once.
5
+ #
6
+ # Once ensures that a given block of code is executed only one time,
7
+ # even when called from multiple threads. After the first execution,
8
+ # subsequent calls are no-ops.
9
+ #
10
+ # @example
11
+ # setup = Once.new { puts "Initializing..." }
12
+ # setup.call # prints "Initializing..."
13
+ # setup.call # does nothing
14
+ # setup.call # does nothing
15
+ class Once
16
+ # Creates a new Once instance with the given block.
17
+ #
18
+ # @yield the block to execute once
19
+ def initialize(&block)
20
+ @block = block
21
+ @mutex = Mutex.new
22
+ end
23
+
24
+ # Executes the block if it hasn't been executed yet.
25
+ #
26
+ # Thread-safe: if multiple threads call this simultaneously,
27
+ # only one will execute the block.
28
+ #
29
+ # @return [void]
30
+ def call
31
+ @mutex&.synchronize do
32
+ return unless @mutex
33
+
34
+ @block.call
35
+
36
+ @mutex = nil
37
+ end
38
+ end
39
+ end
40
+ end
data/lib/wytch/page.rb ADDED
@@ -0,0 +1,136 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wytch
4
+ # Represents a single page in a Wytch site.
5
+ #
6
+ # Pages are defined by Ruby content files in the content directory. Each content
7
+ # file is evaluated in the context of a Page instance, allowing it to set metadata
8
+ # and configure the view class.
9
+ #
10
+ # @example A typical content file (content/about.rb)
11
+ # view MySite::AboutView
12
+ # add MySite::PageHelpers
13
+ #
14
+ # @metadata[:title] = "About Us"
15
+ # @metadata[:description] = "Learn more about our company"
16
+ #
17
+ # @example Accessing page data in a view
18
+ # class MySite::AboutView < Phlex::HTML
19
+ # def initialize(page)
20
+ # @page = page
21
+ # end
22
+ #
23
+ # def view_template
24
+ # h1 { @page.metadata[:title] }
25
+ # end
26
+ # end
27
+ class Page
28
+ # Creates a new page by loading and evaluating a content file.
29
+ #
30
+ # @param file_path [String] path to the content file, relative to the content directory
31
+ def initialize(file_path:)
32
+ @file_path = file_path
33
+ @metadata = {}
34
+ @view_class = nil
35
+
36
+ source_path = File.join(Wytch.site.content_dir, @file_path)
37
+
38
+ instance_eval File.read(source_path), source_path
39
+ end
40
+
41
+ # @return [Hash] arbitrary metadata set by the content file
42
+ attr_reader :metadata
43
+
44
+ # Renders the page using its configured view class.
45
+ #
46
+ # @return [String] the rendered HTML
47
+ def render
48
+ @view_class.new(self).call
49
+ end
50
+
51
+ # Returns the URL path for this page.
52
+ #
53
+ # @return [String] the URL path (e.g., "/" for index, "/about" for about.rb)
54
+ def path
55
+ if virtual_path == "index"
56
+ "/"
57
+ else
58
+ "/#{virtual_path}"
59
+ end
60
+ end
61
+
62
+ # Returns the virtual path derived from the file path.
63
+ #
64
+ # @return [String] the path without directory prefix or .rb suffix
65
+ def virtual_path
66
+ @file_path.delete_prefix("#{Wytch.site.content_dir}/").delete_suffix(".rb")
67
+ end
68
+
69
+ # Returns the output file path for the built page.
70
+ #
71
+ # @return [String] the build path (e.g., "about/index.html")
72
+ def build_path
73
+ if virtual_path == "index"
74
+ "index.html"
75
+ else
76
+ "#{virtual_path}/index.html"
77
+ end
78
+ end
79
+
80
+ # Extends this page instance with a helper module.
81
+ # Called from content files to add helper methods.
82
+ #
83
+ # @param helper_module [Module] the module to extend this page with
84
+ # @return [void]
85
+ #
86
+ # @example
87
+ # add MySite::PostHelpers
88
+ def add(helper_module)
89
+ extend helper_module
90
+ end
91
+
92
+ # Sets the view class for rendering this page.
93
+ # Called from content files to specify which Phlex view to use.
94
+ #
95
+ # @param view_class [Class] a Phlex view class that accepts the page in its initializer
96
+ # @return [void]
97
+ #
98
+ # @example
99
+ # view MySite::PostView
100
+ def view(view_class)
101
+ @view_class = view_class
102
+ end
103
+
104
+ # Whether this page should be included in the sitemap.
105
+ # Override in subclasses to exclude pages.
106
+ #
107
+ # @return [Boolean] true by default
108
+ def include_in_sitemap?
109
+ true
110
+ end
111
+
112
+ # The last modification date for sitemap generation.
113
+ # Override in subclasses to provide actual dates.
114
+ #
115
+ # @return [Date, Time, nil] the last modified date, or nil if unknown
116
+ def last_modified
117
+ nil
118
+ end
119
+
120
+ # The change frequency hint for sitemap generation.
121
+ # Override in subclasses to provide hints like "daily", "weekly", etc.
122
+ #
123
+ # @return [String, nil] the change frequency, or nil if unknown
124
+ def change_frequency
125
+ nil
126
+ end
127
+
128
+ # The priority hint for sitemap generation.
129
+ # Override in subclasses to provide a value between 0.0 and 1.0.
130
+ #
131
+ # @return [Float, nil] the priority, or nil if unknown
132
+ def priority
133
+ nil
134
+ end
135
+ end
136
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "concurrent/atomic/read_write_lock"
4
+ require "listen"
5
+
6
+ module Wytch
7
+ # Coordinates hot reloading of site code and content during development.
8
+ #
9
+ # The ReloadCoordinator watches the site code and content directories for
10
+ # changes using the Listen gem. When files change, it marks the appropriate
11
+ # component as dirty and reloads it on the next request.
12
+ #
13
+ # It uses a read-write lock to ensure thread-safe reloading while allowing
14
+ # concurrent reads during page rendering.
15
+ #
16
+ # @see Wytch::SiteCodeLoaderMiddleware
17
+ class ReloadCoordinator
18
+ # @return [Concurrent::ReadWriteLock] lock for coordinating reloads
19
+ attr_reader :reload_lock
20
+
21
+ # Creates a new ReloadCoordinator and sets up file watchers.
22
+ def initialize
23
+ @reload_lock = Concurrent::ReadWriteLock.new
24
+
25
+ @site_code_dirty = true
26
+ @content_dirty = true
27
+
28
+ @start_site_code_listener = Once.new do
29
+ Listen.to(Wytch.site.site_code_path) do
30
+ @site_code_dirty = true
31
+ end.start
32
+ end
33
+
34
+ @start_content_listener = Once.new do
35
+ Listen.to(Wytch.site.content_dir) do
36
+ @content_dirty = true
37
+ end.start
38
+ end
39
+ end
40
+
41
+ # Reloads site code and/or content if changes have been detected.
42
+ #
43
+ # Starts file listeners on first call. If site code has changed, reloads
44
+ # both site code (via Zeitwerk) and content. If only content has changed,
45
+ # reloads just the content.
46
+ #
47
+ # @return [void]
48
+ def reload!
49
+ @start_site_code_listener&.call
50
+ @start_content_listener&.call
51
+
52
+ return unless @site_code_dirty || @content_dirty
53
+
54
+ reload_lock.with_write_lock do
55
+ if @site_code_dirty
56
+ # Site code changed: reload site code then reload content
57
+ @site_code_dirty = false
58
+ Wytch.site.site_code_loader.reload
59
+ Wytch.site.load_content
60
+ elsif @content_dirty
61
+ # Only content changed: just reload content
62
+ @content_dirty = false
63
+ Wytch.site.load_content
64
+ end
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rack"
4
+
5
+ module Wytch
6
+ # Development server for Wytch sites.
7
+ #
8
+ # The Server provides a local development environment with:
9
+ # - Hot reloading of site code and content
10
+ # - Static file serving from public/
11
+ # - Page rendering on each request
12
+ #
13
+ # @example Starting via CLI
14
+ # $ wytch server
15
+ # $ wytch server --port 3000 --host 0.0.0.0
16
+ #
17
+ # @example Starting programmatically
18
+ # Wytch::Server.new(port: 3000).start
19
+ class Server
20
+ # @return [Hash] server options (port, host)
21
+ attr_reader :options
22
+
23
+ # Creates a new server instance.
24
+ #
25
+ # @param options [Hash] server options
26
+ # @option options [Integer] :port the port to listen on (default: 6969)
27
+ # @option options [String] :host the host to bind to (default: "localhost")
28
+ def initialize(options = {})
29
+ @options = options
30
+ end
31
+
32
+ # Starts the development server.
33
+ #
34
+ # Loads the site configuration, starts file watchers for hot reloading,
35
+ # and begins serving requests. This method blocks until the server is stopped.
36
+ #
37
+ # @return [void]
38
+ def start
39
+ ENV["RACK_ENV"] = "development"
40
+ Site.load!
41
+
42
+ require "puma"
43
+ require "puma/server"
44
+
45
+ port = options[:port] || 6969
46
+ host = options[:host] || "localhost"
47
+
48
+ puts "Starting Wytch development server on http://#{host}:#{port}"
49
+ puts "Press Ctrl+C to stop"
50
+
51
+ server = Puma::Server.new(app)
52
+ server.add_tcp_listener(host, port)
53
+ server.run.join
54
+ end
55
+
56
+ private
57
+
58
+ # Builds the Rack application stack.
59
+ #
60
+ # @return [Rack::Builder] the configured Rack app
61
+ def app
62
+ base_app = lambda { |env|
63
+ path = env["PATH_INFO"]
64
+
65
+ # Otherwise try to serve a page
66
+ page = Wytch.site.pages[path]
67
+
68
+ if page
69
+ body = page.render
70
+ [
71
+ 200,
72
+ {"content-type" => "text/html"},
73
+ [body]
74
+ ]
75
+ else
76
+ [
77
+ 404,
78
+ {"content-type" => "text/html"},
79
+ ["<html><body><h1>404 Not Found</h1><p>Page not found: #{path}</p></body></html>"]
80
+ ]
81
+ end
82
+ }
83
+
84
+ reload_coordinator = ReloadCoordinator.new
85
+
86
+ Rack::Builder.new do
87
+ use Rack::Static, urls: [""], root: "public", cascade: true
88
+
89
+ run SiteCodeLoaderMiddleware.new(base_app, reload_coordinator)
90
+ end
91
+ end
92
+ end
93
+ end