From 113d1d09ddc46aff5b96cd2ebe0d2c8104792635 Mon Sep 17 00:00:00 2001 From: Sampo Kuokkanen Date: Tue, 18 Aug 2026 16:56:47 +0900 Subject: [PATCH 1/3] Enable GitHub Flavored Markdown extensions. `Markly.render_html` enables no extensions by default, so tables were rendered as literal text, `~~strikethrough~~` was left as-is, bare URLs were not linked, and task lists showed `[ ]`. Enable the standard set, as most Markdown intended for preview is GitHub Flavored. --- lib/luna/middleware/markdown.rb | 5 ++++- test/luna/middleware/markdown.rb | 17 +++++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/lib/luna/middleware/markdown.rb b/lib/luna/middleware/markdown.rb index 10bc73a..05feb59 100644 --- a/lib/luna/middleware/markdown.rb +++ b/lib/luna/middleware/markdown.rb @@ -16,6 +16,9 @@ module Middleware # Render Markdown files to HTML on-the-fly. # Intercepts requests that target .md/.markdown files. class Markdown < Protocol::HTTP::Middleware + # 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"]) super(app) @root = File.expand_path(root) @@ -56,7 +59,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("&","&").gsub("<","<").gsub(">",">")} diff --git a/test/luna/middleware/markdown.rb b/test/luna/middleware/markdown.rb index 3a59755..d320989 100644 --- a/test/luna/middleware/markdown.rb +++ b/test/luna/middleware/markdown.rb @@ -35,4 +35,21 @@ def request(method, path) response = middleware.call(request("GET", "/docs/")) expect(response.status).to be == 200 end + + it "renders tables" do + FileUtils.mkdir_p(www_root) + File.write(File.join(www_root, "table.md"), "| A | B |\n|---|---|\n| 1 | 2 |\n") + response = middleware.call(request("GET", "/table.md")) + expect(response.read).to be(:include?, "") + end + + it "renders strikethrough, autolinks and task lists" do + FileUtils.mkdir_p(www_root) + File.write(File.join(www_root, "gfm.md"), "~~gone~~ https://example.com\n\n- [x] done\n") + response = middleware.call(request("GET", "/gfm.md")) + body = response.read + expect(body).to be(:include?, "gone") + expect(body).to be(:include?, "") + expect(body).to be(:include?, " Date: Thu, 20 Aug 2026 17:48:01 +0900 Subject: [PATCH 2/3] Render Markdown and directory listings as complete documents. The Markdown renderer returned a bare fragment, with no doctype, charset or title, and thus nowhere to attach a stylesheet. The directory listing built a document of its own, so the two disagreed. Introduce `Luna::Document`, which both now share, and serve Luna's own assets from `public/` as the last middleware in the stack, so that files in the root take precedence and can override them. Bundle `@socketry/syntax` to highlight code, which can be disabled with `--no-syntax-highlighting`. --- lib/luna.rb | 2 + lib/luna/command/serve.rb | 6 +- lib/luna/document.rb | 59 ++ lib/luna/middleware/markdown.rb | 8 +- lib/luna/middleware/static.rb | 15 +- lib/luna/server.rb | 22 +- luna.gemspec | 2 +- public/_components/@socketry/syntax/Syntax.js | 356 +++++++ .../@socketry/syntax/Syntax/CodeElement.js | 416 +++++++++ .../@socketry/syntax/Syntax/Errors.js | 52 ++ .../@socketry/syntax/Syntax/Language.js | 279 ++++++ .../syntax/Syntax/Language/apache.js | 49 + .../syntax/Syntax/Language/applescript.js | 157 ++++ .../syntax/Syntax/Language/assembly.js | 42 + .../syntax/Syntax/Language/bash-script.js | 108 +++ .../@socketry/syntax/Syntax/Language/bash.js | 32 + .../@socketry/syntax/Syntax/Language/basic.js | 232 +++++ .../@socketry/syntax/Syntax/Language/c++.js | 1 + .../@socketry/syntax/Syntax/Language/c.js | 1 + .../@socketry/syntax/Syntax/Language/clang.js | 201 ++++ .../@socketry/syntax/Syntax/Language/cpp.js | 1 + .../syntax/Syntax/Language/csharp.js | 166 ++++ .../@socketry/syntax/Syntax/Language/css.js | 244 +++++ .../@socketry/syntax/Syntax/Language/diff.js | 24 + .../@socketry/syntax/Syntax/Language/go.js | 135 +++ .../syntax/Syntax/Language/haskell.js | 110 +++ .../@socketry/syntax/Syntax/Language/html.js | 69 ++ .../@socketry/syntax/Syntax/Language/io.js | 68 ++ .../@socketry/syntax/Syntax/Language/java.js | 134 +++ .../syntax/Syntax/Language/javascript.js | 89 ++ .../@socketry/syntax/Syntax/Language/json.js | 36 + .../@socketry/syntax/Syntax/Language/lisp.js | 38 + .../@socketry/syntax/Syntax/Language/lua.js | 87 ++ .../syntax/Syntax/Language/markdown.js | 112 +++ .../@socketry/syntax/Syntax/Language/nginx.js | 37 + .../syntax/Syntax/Language/objective-c.js | 1 + .../@socketry/syntax/Syntax/Language/ocaml.js | 225 +++++ .../syntax/Syntax/Language/pascal.js | 166 ++++ .../@socketry/syntax/Syntax/Language/patch.js | 2 + .../@socketry/syntax/Syntax/Language/perl5.js | 317 +++++++ .../syntax/Syntax/Language/php-script.js | 112 +++ .../@socketry/syntax/Syntax/Language/php.js | 18 + .../@socketry/syntax/Syntax/Language/plain.js | 20 + .../syntax/Syntax/Language/protobuf.js | 77 ++ .../syntax/Syntax/Language/python.js | 208 +++++ .../@socketry/syntax/Syntax/Language/ruby.js | 124 +++ .../@socketry/syntax/Syntax/Language/scala.js | 81 ++ .../syntax/Syntax/Language/smalltalk.js | 30 + .../@socketry/syntax/Syntax/Language/sql.js | 865 ++++++++++++++++++ .../syntax/Syntax/Language/super-collider.js | 70 ++ .../@socketry/syntax/Syntax/Language/swift.js | 176 ++++ .../@socketry/syntax/Syntax/Language/xml.js | 76 ++ .../@socketry/syntax/Syntax/Language/xrb.js | 33 + .../@socketry/syntax/Syntax/Language/yaml.js | 29 + .../@socketry/syntax/Syntax/Loader.js | 78 ++ .../@socketry/syntax/Syntax/Match.js | 552 +++++++++++ .../@socketry/syntax/Syntax/Rule.js | 322 +++++++ .../_components/@socketry/syntax/license.md | 21 + .../_components/@socketry/syntax/package.json | 44 + public/_components/@socketry/syntax/readme.md | 190 ++++ .../@socketry/syntax/themes/base/apache.css | 1 + .../syntax/themes/base/applescript.css | 1 + .../@socketry/syntax/themes/base/assembly.css | 1 + .../@socketry/syntax/themes/base/bash.css | 1 + .../@socketry/syntax/themes/base/basic.css | 1 + .../@socketry/syntax/themes/base/c.css | 1 + .../@socketry/syntax/themes/base/clang.css | 0 .../@socketry/syntax/themes/base/csharp.css | 1 + .../@socketry/syntax/themes/base/css.css | 22 + .../@socketry/syntax/themes/base/diff.css | 48 + .../@socketry/syntax/themes/base/go.css | 1 + .../@socketry/syntax/themes/base/haskell.css | 1 + .../@socketry/syntax/themes/base/html.css | 1 + .../@socketry/syntax/themes/base/io.css | 1 + .../@socketry/syntax/themes/base/java.css | 1 + .../syntax/themes/base/javascript.css | 1 + .../@socketry/syntax/themes/base/json.css | 41 + .../@socketry/syntax/themes/base/lisp.css | 1 + .../@socketry/syntax/themes/base/lua.css | 1 + .../@socketry/syntax/themes/base/markdown.css | 16 + .../@socketry/syntax/themes/base/nginx.css | 1 + .../@socketry/syntax/themes/base/ocaml.css | 1 + .../@socketry/syntax/themes/base/pascal.css | 1 + .../@socketry/syntax/themes/base/perl5.css | 1 + .../syntax/themes/base/php-script.css | 1 + .../@socketry/syntax/themes/base/php.css | 1 + .../@socketry/syntax/themes/base/plain.css | 1 + .../@socketry/syntax/themes/base/protobuf.css | 1 + .../@socketry/syntax/themes/base/python.css | 1 + .../@socketry/syntax/themes/base/ruby.css | 23 + .../@socketry/syntax/themes/base/scala.css | 3 + .../syntax/themes/base/smalltalk.css | 1 + .../@socketry/syntax/themes/base/sql.css | 1 + .../syntax/themes/base/super-collider.css | 33 + .../@socketry/syntax/themes/base/swift.css | 1 + .../@socketry/syntax/themes/base/syntax.css | 74 ++ .../@socketry/syntax/themes/base/xml.css | 1 + .../@socketry/syntax/themes/base/xrb.css | 29 + .../@socketry/syntax/themes/base/yaml.css | 1 + .../@socketry/syntax/themes/theming.md | 244 +++++ public/_static/application.js | 6 + public/_static/luna.css | 39 + test/integration/server_integration.rb | 44 + test/luna/document.rb | 45 + 104 files changed, 7905 insertions(+), 18 deletions(-) create mode 100644 lib/luna/document.rb create mode 100644 public/_components/@socketry/syntax/Syntax.js create mode 100644 public/_components/@socketry/syntax/Syntax/CodeElement.js create mode 100644 public/_components/@socketry/syntax/Syntax/Errors.js create mode 100644 public/_components/@socketry/syntax/Syntax/Language.js create mode 100644 public/_components/@socketry/syntax/Syntax/Language/apache.js create mode 100644 public/_components/@socketry/syntax/Syntax/Language/applescript.js create mode 100644 public/_components/@socketry/syntax/Syntax/Language/assembly.js create mode 100644 public/_components/@socketry/syntax/Syntax/Language/bash-script.js create mode 100644 public/_components/@socketry/syntax/Syntax/Language/bash.js create mode 100644 public/_components/@socketry/syntax/Syntax/Language/basic.js create mode 100644 public/_components/@socketry/syntax/Syntax/Language/c++.js create mode 100644 public/_components/@socketry/syntax/Syntax/Language/c.js create mode 100644 public/_components/@socketry/syntax/Syntax/Language/clang.js create mode 100644 public/_components/@socketry/syntax/Syntax/Language/cpp.js create mode 100644 public/_components/@socketry/syntax/Syntax/Language/csharp.js create mode 100644 public/_components/@socketry/syntax/Syntax/Language/css.js create mode 100644 public/_components/@socketry/syntax/Syntax/Language/diff.js create mode 100644 public/_components/@socketry/syntax/Syntax/Language/go.js create mode 100644 public/_components/@socketry/syntax/Syntax/Language/haskell.js create mode 100644 public/_components/@socketry/syntax/Syntax/Language/html.js create mode 100644 public/_components/@socketry/syntax/Syntax/Language/io.js create mode 100644 public/_components/@socketry/syntax/Syntax/Language/java.js create mode 100644 public/_components/@socketry/syntax/Syntax/Language/javascript.js create mode 100644 public/_components/@socketry/syntax/Syntax/Language/json.js create mode 100644 public/_components/@socketry/syntax/Syntax/Language/lisp.js create mode 100644 public/_components/@socketry/syntax/Syntax/Language/lua.js create mode 100644 public/_components/@socketry/syntax/Syntax/Language/markdown.js create mode 100644 public/_components/@socketry/syntax/Syntax/Language/nginx.js create mode 100644 public/_components/@socketry/syntax/Syntax/Language/objective-c.js create mode 100644 public/_components/@socketry/syntax/Syntax/Language/ocaml.js create mode 100644 public/_components/@socketry/syntax/Syntax/Language/pascal.js create mode 100644 public/_components/@socketry/syntax/Syntax/Language/patch.js create mode 100644 public/_components/@socketry/syntax/Syntax/Language/perl5.js create mode 100644 public/_components/@socketry/syntax/Syntax/Language/php-script.js create mode 100644 public/_components/@socketry/syntax/Syntax/Language/php.js create mode 100644 public/_components/@socketry/syntax/Syntax/Language/plain.js create mode 100644 public/_components/@socketry/syntax/Syntax/Language/protobuf.js create mode 100644 public/_components/@socketry/syntax/Syntax/Language/python.js create mode 100644 public/_components/@socketry/syntax/Syntax/Language/ruby.js create mode 100644 public/_components/@socketry/syntax/Syntax/Language/scala.js create mode 100644 public/_components/@socketry/syntax/Syntax/Language/smalltalk.js create mode 100644 public/_components/@socketry/syntax/Syntax/Language/sql.js create mode 100644 public/_components/@socketry/syntax/Syntax/Language/super-collider.js create mode 100644 public/_components/@socketry/syntax/Syntax/Language/swift.js create mode 100644 public/_components/@socketry/syntax/Syntax/Language/xml.js create mode 100644 public/_components/@socketry/syntax/Syntax/Language/xrb.js create mode 100644 public/_components/@socketry/syntax/Syntax/Language/yaml.js create mode 100644 public/_components/@socketry/syntax/Syntax/Loader.js create mode 100644 public/_components/@socketry/syntax/Syntax/Match.js create mode 100644 public/_components/@socketry/syntax/Syntax/Rule.js create mode 100644 public/_components/@socketry/syntax/license.md create mode 100644 public/_components/@socketry/syntax/package.json create mode 100644 public/_components/@socketry/syntax/readme.md create mode 100644 public/_components/@socketry/syntax/themes/base/apache.css create mode 100644 public/_components/@socketry/syntax/themes/base/applescript.css create mode 100644 public/_components/@socketry/syntax/themes/base/assembly.css create mode 100644 public/_components/@socketry/syntax/themes/base/bash.css create mode 100644 public/_components/@socketry/syntax/themes/base/basic.css create mode 100644 public/_components/@socketry/syntax/themes/base/c.css create mode 100644 public/_components/@socketry/syntax/themes/base/clang.css create mode 100644 public/_components/@socketry/syntax/themes/base/csharp.css create mode 100644 public/_components/@socketry/syntax/themes/base/css.css create mode 100644 public/_components/@socketry/syntax/themes/base/diff.css create mode 100644 public/_components/@socketry/syntax/themes/base/go.css create mode 100644 public/_components/@socketry/syntax/themes/base/haskell.css create mode 100644 public/_components/@socketry/syntax/themes/base/html.css create mode 100644 public/_components/@socketry/syntax/themes/base/io.css create mode 100644 public/_components/@socketry/syntax/themes/base/java.css create mode 100644 public/_components/@socketry/syntax/themes/base/javascript.css create mode 100644 public/_components/@socketry/syntax/themes/base/json.css create mode 100644 public/_components/@socketry/syntax/themes/base/lisp.css create mode 100644 public/_components/@socketry/syntax/themes/base/lua.css create mode 100644 public/_components/@socketry/syntax/themes/base/markdown.css create mode 100644 public/_components/@socketry/syntax/themes/base/nginx.css create mode 100644 public/_components/@socketry/syntax/themes/base/ocaml.css create mode 100644 public/_components/@socketry/syntax/themes/base/pascal.css create mode 100644 public/_components/@socketry/syntax/themes/base/perl5.css create mode 100644 public/_components/@socketry/syntax/themes/base/php-script.css create mode 100644 public/_components/@socketry/syntax/themes/base/php.css create mode 100644 public/_components/@socketry/syntax/themes/base/plain.css create mode 100644 public/_components/@socketry/syntax/themes/base/protobuf.css create mode 100644 public/_components/@socketry/syntax/themes/base/python.css create mode 100644 public/_components/@socketry/syntax/themes/base/ruby.css create mode 100644 public/_components/@socketry/syntax/themes/base/scala.css create mode 100644 public/_components/@socketry/syntax/themes/base/smalltalk.css create mode 100644 public/_components/@socketry/syntax/themes/base/sql.css create mode 100644 public/_components/@socketry/syntax/themes/base/super-collider.css create mode 100644 public/_components/@socketry/syntax/themes/base/swift.css create mode 100644 public/_components/@socketry/syntax/themes/base/syntax.css create mode 100644 public/_components/@socketry/syntax/themes/base/xml.css create mode 100644 public/_components/@socketry/syntax/themes/base/xrb.css create mode 100644 public/_components/@socketry/syntax/themes/base/yaml.css create mode 100644 public/_components/@socketry/syntax/themes/theming.md create mode 100644 public/_static/application.js create mode 100644 public/_static/luna.css create mode 100644 test/luna/document.rb diff --git a/lib/luna.rb b/lib/luna.rb index 151cf0e..440b359 100644 --- a/lib/luna.rb +++ b/lib/luna.rb @@ -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 diff --git a/lib/luna/command/serve.rb b/lib/luna/command/serve.rb index dfce281..2e9cea9 100644 --- a/lib/luna/command/serve.rb +++ b/lib/luna/command/serve.rb @@ -24,6 +24,7 @@ class Serve < Samovar::Command option "-i/--index ", "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 @@ -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 @@ -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| diff --git a/lib/luna/document.rb b/lib/luna/document.rb new file mode 100644 index 0000000..4bbf2b0 --- /dev/null +++ b/lib/luna/document.rb @@ -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 + + + + #{escape_html(title)} + #{head.join("\n")} + #{body} + HTML + end + + private + + def head + links = [] + + if @stylesheet + links << "" + end + + @scripts.each do |script| + links << "" + end + + links + end + + def escape_html(text) + text.to_s.gsub("&", "&").gsub("<", "<").gsub(">", ">").gsub('"', """) + end + end +end diff --git a/lib/luna/middleware/markdown.rb b/lib/luna/middleware/markdown.rb index 05feb59..48a0e1b 100644 --- a/lib/luna/middleware/markdown.rb +++ b/lib/luna/middleware/markdown.rb @@ -5,6 +5,8 @@ require "protocol/http/middleware" +require_relative "../document" + begin require "markly" rescue LoadError @@ -19,14 +21,16 @@ class Markdown < Protocol::HTTP::Middleware # 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"]) + 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)) @@ -69,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"] ] diff --git a/lib/luna/middleware/static.rb b/lib/luna/middleware/static.rb index bc214c9..f8892cd 100644 --- a/lib/luna/middleware/static.rb +++ b/lib/luna/middleware/static.rb @@ -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. @@ -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" @@ -99,13 +103,8 @@ def serve_directory_listing(dir_path, request_path) target += "/" if File.directory?(File.join(dir_path, e)) && !target.end_with?("/") "
  • #{escape_html(name)}
  • " end.join("\n") - html = <<~HTML - - - Index of #{escape_html(request_path)} -

    Index of #{escape_html(request_path)}

    - - HTML + title = "Index of #{request_path}" + html = @document.call(title, "

    #{escape_html(title)}

    \n") headers = [ ["content-type", "text/html; charset=utf-8"] diff --git a/lib/luna/server.rb b/lib/luna/server.rb index 5ae33c4..170e0f5 100644 --- a/lib/luna/server.rb +++ b/lib/luna/server.rb @@ -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" @@ -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 diff --git a/luna.gemspec b/luna.gemspec index 74d4af6..06f6166 100644 --- a/luna.gemspec +++ b/luna.gemspec @@ -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"] diff --git a/public/_components/@socketry/syntax/Syntax.js b/public/_components/@socketry/syntax/Syntax.js new file mode 100644 index 0000000..b3a1b5d --- /dev/null +++ b/public/_components/@socketry/syntax/Syntax.js @@ -0,0 +1,356 @@ +/** + * Syntax - Core highlighting engine + * A modern, framework-agnostic syntax highlighter + * + * @package @socketry/syntax + * @author Samuel G. D. Williams + * @license MIT + */ + +import {Loader} from './Syntax/Loader.js'; +import { + LanguageNotFoundError, + LanguageLoadError, + StyleSheetLoadError +} from './Syntax/Errors.js'; + +export class Syntax { + static #default = null; + + #root = null; + #aliases = {}; + #languages = new Loader((loader, name) => this.#loadLanguage(loader, name)); + #styleSheets = new Loader((loader, url) => this.#loadStyleSheet(loader, url)); + #styles = {}; + #themes = {}; + #themeRoot = null; // Base URL for theme assets (CSS) + + #defaultOptions = { + theme: 'base', + linkify: true, + strict: false + }; + + /** + * Get or create the default Syntax instance + */ + static get default() { + if (!this.#default) { + this.#default = new Syntax(); + } + return this.#default; + } + + /** + * Set a custom default Syntax instance + */ + static set default(instance) { + this.#default = instance; + } + + /** + * Detect the default root path for loading language modules + * Uses import.meta.url to reliably locate the Syntax module directory + */ + static detectRoot() { + try { + const url = new URL('./', import.meta.url); + return url.href; + } catch (error) { + // Fallback: try document.currentScript (may not work reliably) + if ( + typeof document !== 'undefined' && + document.currentScript && + document.currentScript.src + ) { + const url = new URL(document.currentScript.src); + return url.pathname.substring(0, url.pathname.lastIndexOf('/') + 1); + } + // Last resort fallback + return '/'; + } + } + + /** + * Initialize syntax highlighting on the page + * Registers the web component and sets up the default syntax instance + * Languages will be auto-loaded on demand when referenced by elements + * + * @param {Object} options - Configuration options + * @param {Syntax} options.syntax - Syntax instance to use (defaults to Syntax.default) + * @param {boolean} options.upgradeAll - Whether to automatically upgrade existing elements (default: true) + * @param {string} options.selector - CSS selector for upgrading (default: 'code[class*="language-"]') + * @param {string} options.root - Base URL for loading language modules (default: auto-detected) + * @returns {Promise} + */ + static async highlight(options = {}) { + const { + syntax = Syntax.default, + upgradeAll: shouldUpgradeAll = true, + selector = 'code[class*="language-"]', + root = null + } = options; + + // Set the default syntax instance: + Syntax.default = syntax; + + // Configure root for auto-loading if provided: + if (root && !syntax.root) { + syntax.root = root; + } + + // Import and register the web component: + const {CodeElement, upgradeAll} = await import('./Syntax/CodeElement.js'); + + if (!customElements.get('syntax-code')) { + customElements.define('syntax-code', CodeElement); + } + + // Upgrade existing code blocks if requested: + if (shouldUpgradeAll) { + if (upgradeAll) { + // Use the upgradeAll function with the selector: + upgradeAll(selector, syntax); + } else { + // Fallback to customElements.upgrade if the function isn't available: + customElements.upgrade(document.body); + } + } + } + + constructor(options = {}) { + // Allow customization via constructor options + if (options.root !== undefined) this.#root = options.root; + if (options.theme !== undefined) this.#defaultOptions.theme = options.theme; + if (options.themeRoot !== undefined) this.#themeRoot = options.themeRoot; + + // Set default root if not provided + if (this.#root === null) { + this.#root = Syntax.detectRoot(); + } + } + + // Public getters for commonly accessed properties + get defaultOptions() { + return this.#defaultOptions; + } + + get languages() { + return this.#languages; + } + + get aliases() { + return this.#aliases; + } + + get styles() { + return this.#styles; + } + + get themes() { + return this.#themes; + } + + // Theme root for CSS assets + get themeRoot() { + return this.#resolveThemeRoot().toString(); + } + + set themeRoot(value) { + this.#themeRoot = value; + // Clear the stylesheet cache when theme changes + this.#styleSheets.clear(); + } + + get root() { + return this.#root; + } + + set root(value) { + this.#root = value; + } + + /** + * Resolve the base URL for the current theme's assets. + * Priority: + * 1) Explicit options.themeRoot or setter + * 2) Relative to this module location: ./themes// + */ + #resolveThemeRoot() { + try { + if (this.#themeRoot) { + return new URL( + this.#themeRoot, + typeof document !== 'undefined' ? document.baseURI : import.meta.url + ); + } + // Default to a folder next to Syntax.js + return new URL(`./themes/${this.#defaultOptions.theme}/`, import.meta.url); + } catch (error) { + // As a last resort, fall back to root if provided or current location: + const base = + this.#root || (typeof location !== 'undefined' ? location.href : ''); + return new URL(`themes/${this.#defaultOptions.theme}/`, base); + } + } + + /** + * Fetch CSS for use in Shadow DOM + * Returns an object with {sheet, cssText} where sheet is a CSSStyleSheet (if supported) + * The result is cached and deduplicated across calls + */ + async getStyleSheet(url) { + return this.#styleSheets.load(url); + } + + /** + * Load a stylesheet from a URL + * Used internally by the stylesheet loader + */ + async #loadStyleSheet(loader, url) { + const response = await fetch(url); + if (!response.ok) { + throw new StyleSheetLoadError(url.toString(), response.status); + } + const cssText = await response.text(); + + // If CSSStyleSheet constructor is available, create and return a stylesheet: + if (typeof CSSStyleSheet !== 'undefined') { + const sheet = new CSSStyleSheet(); + await sheet.replace(cssText); + return {sheet, cssText}; + } + + // Otherwise just return the text: + return {cssText}; + } + + /** + * Load a language module from disk/network + * Used internally by the language loader + */ + async #loadLanguage(loader, name) { + const path = `${this.#root}Syntax/Language/${name}.js`; + let module; + try { + module = await import(path); + } catch (error) { + throw new LanguageLoadError(name, path, {cause: error}); + } + + // If the module exports a register function, call it with this instance + if (typeof module.default === 'function') { + module.default(this); + } + + // After calling register, aliases have been registered. Re-resolve the name: + let resolvedName = this.#aliases[name] || name; + return loader.get(resolvedName); + } + + /** + * Load a language module dynamically + */ + async getResource(name) { + // First check if the language is already loaded (including via alias) + const resolvedName = this.#aliases[name] || name; + if (this.#languages.has(resolvedName)) { + return this.#languages.get(resolvedName); + } + + // Use the loader to deduplicate concurrent loads + return this.#languages.load(resolvedName); + } + + /** + * Register language aliases + */ + alias(name, aliases) { + this.#aliases[name] = name; + + for (const alias of aliases) { + this.#aliases[alias] = name; + } + } + + /** + * Register a language with this Syntax instance + */ + register(name, language) { + // Store directly in the loader's cache using the new set() method + this.#languages.set(name, language); + + // Also store in aliases if not already there + if (!this.#aliases[name]) { + this.#aliases[name] = name; + } + + return language; + } + + /** + * Get a language by name or alias + * Auto-loads the language if not already registered + */ + async getLanguage(name) { + // Resolve alias + const resolvedName = (this.#aliases[name] || name).toLowerCase(); + + // If already loaded, return it + if (this.#languages.has(resolvedName)) { + return this.#languages.get(resolvedName); + } + + // Otherwise, try to load it + try { + return await this.getResource(name); + } catch (error) { + if (this.#defaultOptions.strict && !(error instanceof LanguageLoadError)) { + // If strict and not a load error, ensure a consistent error type: + throw new LanguageNotFoundError(resolvedName, {cause: error}); + } + throw error; + } + } + + /** + * Check if a language is already registered (synchronous) + */ + hasLanguage(name) { + // Resolve alias + const resolvedName = this.#aliases[name] || name; + return this.#languages.has(resolvedName); + } + + /** + * Get all aliases for a language + */ + languageAliases(language) { + const aliases = []; + + for (const [name, target] of Object.entries(this.#aliases)) { + if (target === language) { + aliases.push(name); + } + } + + return aliases; + } + + /** + * Get all language names (primary names, not aliases) + */ + languageNames() { + const names = []; + + for (const [name, target] of Object.entries(this.#aliases)) { + if (name === target) { + names.push(name); + } + } + + return names; + } +} + +export default Syntax; diff --git a/public/_components/@socketry/syntax/Syntax/CodeElement.js b/public/_components/@socketry/syntax/Syntax/CodeElement.js new file mode 100644 index 0000000..4c8de1e --- /dev/null +++ b/public/_components/@socketry/syntax/Syntax/CodeElement.js @@ -0,0 +1,416 @@ +import Syntax from '../Syntax.js'; +import {Match} from './Match.js'; + +const supportsAdopted = + typeof CSSStyleSheet !== 'undefined' && + 'adoptedStyleSheets' in Document.prototype; + +// These values are defined by the DOM standard. Keep them local so this code +// does not depend on a global `Node`, which may be unavailable in non-browser +// DOM implementations. +const ELEMENT_NODE = 1; +const TEXT_NODE = 3; +const CDATA_SECTION_NODE = 4; + +/** + * Extract the source text and existing markup as source-aligned matches. + * + * The highlighting pipeline can then insert these matches into the syntax + * tree, preserving elements such as links while allowing their contents to + * receive syntax highlighting. + */ +function extractCode(root) { + let text = ''; + const matches = []; + + function extract(node) { + if (node.nodeType === TEXT_NODE || node.nodeType === CDATA_SECTION_NODE) { + text += node.nodeValue.replace(/\r/g, ''); + return; + } + + if (node.nodeType !== ELEMENT_NODE) { + return; + } + + if (node.tagName === 'BR') { + text += '\n'; + return; + } + + const offset = text.length; + let match = null; + + if (node !== root) { + match = new Match(offset, 0, {element: node, force: true, allow: '*'}, ''); + matches.push(match); + } + + for (const child of node.childNodes) { + extract(child); + } + + if (match) { + match.length = text.length - offset; + match.endOffset = text.length; + match.value = text.slice(offset); + } + } + + extract(root); + + return {text, matches: matches.filter(match => match.length > 0)}; +} + +/** + * CodeElement - Web Component for syntax highlighting with isolated styles + * + * Usage: + * const x = 1; + *
    puts "Hello"
    + */ +export class CodeElement extends HTMLElement { + static get observedAttributes() { + return ['language', 'theme', 'wrap']; + } + + #syntax = null; + #shadow; + #slot = null; + #rendered = null; + #adoptedHrefs = new Set(); + #highlighted = false; + + constructor() { + super(); + + /** + * A promise that resolves when the current highlighting attempt completes. + * Check `highlighted` before using line measurement APIs. + * @type {Promise} + */ + this.ready = Promise.resolve(); + } + + get syntax() { + return this.#syntax || Syntax.default; + } + + set syntax(value) { + this.#syntax = value; + // Re-render with new syntax instance if already connected: + if (this.isConnected && !this.#highlighted) { + this.ready = this.#render(); + } + } + + get language() { + return ( + this.getAttribute('language') || + this.#detectLanguageFromClass() + ); + } + + set language(value) { + if (value == null) { + this.removeAttribute('language'); + } else { + this.setAttribute('language', value); + } + } + + get theme() { + return this.getAttribute('theme') || this.syntax.defaultOptions.theme; + } + + set theme(value) { + if (value == null) { + this.removeAttribute('theme'); + } else { + this.setAttribute('theme', value); + } + } + + get wrap() { + return this.hasAttribute('wrap'); + } + + set wrap(value) { + if (value) { + this.setAttribute('wrap', ''); + } else { + this.removeAttribute('wrap'); + } + } + + /** + * Get the bounding client rect of a specific line (1-based). + * @param {number} lineNumber - The 1-based line number. + * @returns {DOMRect|null} The bounding rect, or null if not found. + */ + getLineBoundingClientRect(lineNumber) { + if (!this.#shadow) return null; + + const code = this.#shadow.querySelector('code'); + if (!code) return null; + + const lines = code.children; + if (lineNumber < 1 || lineNumber > lines.length) return null; + + return lines[lineNumber - 1].getBoundingClientRect(); + } + + /** + * Get the total number of rendered lines. + * @returns {number} The line count, or 0 if not yet rendered. + */ + get lineCount() { + if (!this.#shadow) return 0; + + const code = this.#shadow.querySelector('code'); + if (!code) return 0; + + return code.children.length; + } + + /** + * Whether the current highlighting attempt has completed successfully. + * @returns {boolean} + */ + get highlighted() { + return this.#highlighted; + } + + connectedCallback() { + // Detect if we're inside a
     element and set wrap attribute
    +		if (this.parentElement?.tagName === 'PRE') {
    +			this.wrap = true;
    +		}
    +
    +		// Don't re-highlight if already done
    +		if (this.#highlighted) {
    +			return;
    +		}
    +
    +		if (!this.#shadow) {
    +			this.#shadow = this.attachShadow({mode: 'open'});
    +			this.#slot = document.createElement('slot');
    +			this.#shadow.appendChild(this.#slot);
    +		}
    +
    +		this.ready = this.#render();
    +	}
    +
    +	attributeChangedCallback(name, oldValue, newValue) {
    +		if (oldValue === newValue) {
    +			return;
    +		}
    +
    +		if (
    +			(name === 'language' || name === 'theme' || name === 'wrap') &&
    +			this.isConnected &&
    +			this.#shadow
    +		) {
    +			// Reset highlighted state and track the new render attempt:
    +			this.#highlighted = false;
    +			this.#adoptedHrefs.clear();
    +			this.ready = this.#render();
    +		}
    +	}
    +
    +	/**
    +	 * Detect language from class names (e.g., language-javascript, brush-ruby)
    +	 */
    +	#detectLanguageFromClass() {
    +		const classes = this.className.split(/\s+/);
    +
    +		for (const cls of classes) {
    +			// Match language-* or brush-* patterns
    +			const match = cls.match(/^(?:language|brush)-(.+)$/);
    +			if (match) {
    +				return match[1];
    +			}
    +		}
    +
    +		return null;
    +	}
    +
    +	/**
    +	 * Get the source text and existing markup to highlight.
    +	 */
    +	#getCodeContent() {
    +		// Check if there's a  child element
    +		const codeElement = this.querySelector('code');
    +		if (codeElement) {
    +			return extractCode(codeElement);
    +		}
    +
    +		return extractCode(this);
    +	}
    +
    +	/**
    +	 * Load theme CSS into shadow root
    +	 */
    +	async #loadStylesheets(languageName) {
    +		// Guard: ensure shadow root exists
    +		if (!this.#shadow) {
    +			return;
    +		}
    +
    +		const themeRoot = new URL(
    +			this.syntax.themeRoot,
    +			typeof document !== 'undefined' ? document.baseURI : import.meta.url
    +		);
    +
    +		const urls = [
    +			new URL('syntax.css', themeRoot),
    +			new URL(`${languageName}.css`, themeRoot)
    +		];
    +
    +		if (supportsAdopted && this.#shadow.adoptedStyleSheets !== undefined) {
    +			const sheets = Array.from(this.#shadow.adoptedStyleSheets);
    +			for (const url of urls) {
    +				const href = url.toString();
    +				if (this.#adoptedHrefs.has(href)) continue;
    +				try {
    +					const result = await this.syntax.getStyleSheet(url);
    +					if (result.sheet) {
    +						sheets.push(result.sheet);
    +						this.#adoptedHrefs.add(href);
    +					}
    +				} catch (error) {
    +					console.warn(`Failed to load ${href}:`, error);
    +				}
    +			}
    +			this.#shadow.adoptedStyleSheets = sheets;
    +		} else {
    +			// Fallback: inline