Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,5 @@
/gems.locked
/.covered.db
/external

/node_modules
1 change: 1 addition & 0 deletions gems.rb
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@

gem "agent-context"

gem "utopia"
gem "utopia-project"

gem "decode"
Expand Down
2 changes: 2 additions & 0 deletions lib/luna.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,6 @@
require_relative "luna/version"

module Luna
# The root directory for Luna's own assets, e.g. stylesheets and scripts.
PUBLIC_ROOT = File.expand_path("../public", __dir__)
end
6 changes: 4 additions & 2 deletions lib/luna/command/serve.rb
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ class Serve < Samovar::Command
option "-i/--index <filename>", "Directory index", default: "index.html"
option "--[no]-directory-listing", "Enable directory listing", default: true
option "--[no]-markdown", "Enable Markdown rendering", default: true
option "--[no]-syntax-highlighting", "Enable syntax highlighting", default: true
option "-v/--[no]-verbose", "Verbose logging", default: false
end

Expand Down Expand Up @@ -55,7 +56,8 @@ def middleware
verbose: @options[:verbose],
markdown: @options[:markdown],
index: @options[:index],
directory_listing: @options[:directory_listing]
directory_listing: @options[:directory_listing],
syntax_highlighting: @options[:syntax_highlighting]
)
end

Expand All @@ -64,7 +66,7 @@ def call
buffer.puts "Luna v#{Luna::VERSION} serving..."
buffer.puts "- Root: #{File.expand_path(root_directory)}"
buffer.puts "- Bind: #{endpoint}"
buffer.puts "- Markdown: #{@options[:markdown]} | Index: #{@options[:index]} | Directory Listing: #{@options[:directory_listing]}"
buffer.puts "- Markdown: #{@options[:markdown]} | Index: #{@options[:index]} | Directory Listing: #{@options[:directory_listing]} | Syntax Highlighting: #{@options[:syntax_highlighting]}"
end

Async do |task|
Expand Down
59 changes: 59 additions & 0 deletions lib/luna/document.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
# frozen_string_literal: true

# Released under the MIT License.
# Copyright, 2025, by Samuel Williams.

module Luna
# Wraps rendered content in a minimal HTML document.
#
# Both the Markdown renderer and the directory listing use this, so that they
# agree on the document structure and share the same stylesheet.
class Document
# @parameter stylesheet [String | Nil] The stylesheet to link, if any.
# @parameter scripts [Array(String)] Module scripts to include.
def initialize(stylesheet: "/_static/luna.css", scripts: [])
@stylesheet = stylesheet
@scripts = scripts
end

# @attribute [String | Nil] The stylesheet to link, if any.
attr :stylesheet

# @attribute [Array(String)] Module scripts to include.
attr :scripts

# @parameter title [String] The title of the document.
# @parameter body [String] The rendered content.
# @returns [String] A complete HTML document.
def call(title, body)
<<~HTML
<!doctype html>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>#{escape_html(title)}</title>
#{head.join("\n")}
#{body}
HTML
end

private

def head
links = []

if @stylesheet
links << "<link rel=\"stylesheet\" href=\"#{escape_html(@stylesheet)}\">"
end

@scripts.each do |script|
links << "<script type=\"module\" src=\"#{escape_html(script)}\"></script>"
end

links
end

def escape_html(text)
text.to_s.gsub("&", "&amp;").gsub("<", "&lt;").gsub(">", "&gt;").gsub('"', "&quot;")
end
end
end
13 changes: 10 additions & 3 deletions lib/luna/middleware/markdown.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@

require "protocol/http/middleware"

require_relative "../document"

begin
require "markly"
rescue LoadError
Expand All @@ -16,14 +18,19 @@ module Middleware
# Render Markdown files to HTML on-the-fly.
# Intercepts requests that target .md/.markdown files.
class Markdown < Protocol::HTTP::Middleware
def initialize(app, root: Dir.pwd, index_candidates: ["index.md", "README.md"])
# The GitHub Flavored Markdown extensions, which are not enabled by default.
EXTENSIONS = [:table, :strikethrough, :autolink, :tagfilter, :tasklist].freeze

def initialize(app, root: Dir.pwd, index_candidates: ["index.md", "README.md"], document: Document.new)
super(app)
@root = File.expand_path(root)
@index_candidates = index_candidates
@document = document
end

attr :root
attr :index_candidates
attr :document

def safe_join(path)
full = File.expand_path(File.join(@root, path))
Expand Down Expand Up @@ -56,7 +63,7 @@ def markdown_file?(path)

def render_html(markdown)
if defined?(::Markly)
::Markly.render_html(markdown)
::Markly.render_html(markdown, extensions: EXTENSIONS)
else
# Fallback minimal rendering if markly isn't available:
escape = ->(s){s.to_s.gsub("&","&amp;").gsub("<","&lt;").gsub(">","&gt;")}
Expand All @@ -66,7 +73,7 @@ def render_html(markdown)

def render_file(path, head: false)
content = File.read(path)
html = render_html(content)
html = @document.call(File.basename(path), render_html(content))
headers = [
["content-type", "text/html; charset=utf-8"]
]
Expand Down
15 changes: 7 additions & 8 deletions lib/luna/middleware/static.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
require "protocol/http/body/file"
require "time"

require_relative "../document"

module Luna
module Middleware
# Serve static files from a root directory, with optional index and directory listing.
Expand All @@ -29,16 +31,18 @@ class Static < Protocol::HTTP::Middleware
".zip" => "application/zip",
}.freeze

def initialize(app, root: Dir.pwd, index: "index.html", directory_listing: true)
def initialize(app, root: Dir.pwd, index: "index.html", directory_listing: true, document: Document.new)
super(app)
@root = File.expand_path(root)
@index = index
@directory_listing = directory_listing
@document = document
end

attr :root
attr :index
attr :directory_listing
attr :document

def mime_type_for_extension(ext)
MIME_TYPES[ext.downcase] || "application/octet-stream"
Expand Down Expand Up @@ -99,13 +103,8 @@ def serve_directory_listing(dir_path, request_path)
target += "/" if File.directory?(File.join(dir_path, e)) && !target.end_with?("/")
"<li><a href=\"#{escape_html(target)}\">#{escape_html(name)}</a></li>"
end.join("\n")
html = <<~HTML
<!doctype html>
<meta charset="utf-8">
<title>Index of #{escape_html(request_path)}</title>
<h1>Index of #{escape_html(request_path)}</h1>
<ul>#{links}</ul>
HTML
title = "Index of #{request_path}"
html = @document.call(title, "<h1>#{escape_html(title)}</h1>\n<ul>#{links}</ul>")

headers = [
["content-type", "text/html; charset=utf-8"]
Expand Down
22 changes: 17 additions & 5 deletions lib/luna/server.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
require "protocol/http/middleware/builder"
require "protocol/http/content_encoding"

require_relative "../luna"
require_relative "document"
require_relative "middleware/verbose"
require_relative "middleware/static"
require_relative "middleware/markdown"
Expand All @@ -20,16 +22,26 @@ class Server < Async::HTTP::Server
# @param markdown [Boolean] Enable markdown rendering.
# @param index [String] Index file for directories.
# @param directory_listing [Boolean] Enable basic directory listing when no index exists.
def self.middleware(root: Dir.pwd, verbose: false, markdown: true, index: "index.html", directory_listing: true)
# @param syntax_highlighting [Boolean] Highlight code using Luna's bundled syntax highlighter.
def self.middleware(root: Dir.pwd, verbose: false, markdown: true, index: "index.html", directory_listing: true, syntax_highlighting: true)
scripts = syntax_highlighting ? ["/_static/application.js"] : []
document = Luna::Document.new(scripts: scripts)

::Protocol::HTTP::Middleware.build do
use Luna::Middleware::Verbose if verbose
use ::Protocol::HTTP::ContentEncoding
use Luna::Middleware::Markdown, root: root if markdown
run Luna::Middleware::Static.new(
->(request){Protocol::HTTP::Response[404, {"content-type"=>"text/plain"}, ["Not Found"]]},
use Luna::Middleware::Markdown, root: root, document: document if markdown
use Luna::Middleware::Static,
root: root,
index: index,
directory_listing: directory_listing
directory_listing: directory_listing,
document: document

# Luna's own assets are served last, so that files in the root take precedence:
run Luna::Middleware::Static.new(
->(request){Protocol::HTTP::Response[404, {"content-type"=>"text/plain"}, ["Not Found"]]},
root: Luna::PUBLIC_ROOT,
directory_listing: false
)
end
end
Expand Down
2 changes: 1 addition & 1 deletion luna.gemspec
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ Gem::Specification.new do |spec|
"source_code_uri" => "https://github.com/socketry/luna.git",
}

spec.files = Dir.glob(["{bin,examples,lib}/**/*", "*.md"], File::FNM_DOTMATCH, base: __dir__)
spec.files = Dir.glob(["{bin,examples,lib,public}/**/*", "*.md"], File::FNM_DOTMATCH, base: __dir__)

spec.executables = ["luna"]

Expand Down
18 changes: 18 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"dependencies": {
"@socketry/syntax": "^0.6.1"
}
}
Loading
Loading