class Syntropy::Router
def [](path)
def [](path) get_route_entry(path) end
def add_route(fn)
def add_route(fn) kind = route_kind(fn) rel_path = path_rel(fn) canonical_path = path_canonical(rel_path, kind) entry = { kind:, fn:, canonical_path: } entry[:handle_subtree] = true if (kind == :module) && !!(fn =~ /\+\.rb$/) @routes[canonical_path] = entry @files[fn] = entry end
def calc_route_proc_with_hooks(entry, proc)
def calc_route_proc_with_hooks(entry, proc) compose_up_tree_hooks(entry[:fn], proc) end
def compose_up_tree_hooks(path, proc)
def compose_up_tree_hooks(path, proc) parent = File.dirname(path) proc = hook_wrap_if_exists(File.join(parent, '_hook.rb'), proc) proc = error_handler_wrap_if_exists(File.join(parent, '_error.rb'), proc) return proc if parent == @root compose_up_tree_hooks(parent, proc) end
def error_handler_wrap_if_exists(error_handler_fn, proc)
def error_handler_wrap_if_exists(error_handler_fn, proc) return proc if !File.file?(error_handler_fn) ref = path_rel(error_handler_fn).gsub(/\.rb$/, '') error_proc = @module_loader.load(ref) proc do |req| proc.(req) rescue StandardError => e error_proc.(req, e) end end
def file_watcher_loop
def file_watcher_loop wf = @opts[:watch_files] period = wf.is_a?(Numeric) ? wf : 0.1 Syntropy.file_watch(@machine, @root, period: period) do |event, fn| handle_changed_file(event, fn) rescue Exception => e p e p e.backtrace exit! end end
def find_index_route(path)
def find_index_route(path) m = path.match(INDEX_OPT_EXT_RE) return nil if !m @routes[m[1]] end
def find_route_entry(path)
def find_route_entry(path) return NOT_FOUND if path =~ FORBIDDEN_RE @routes[path] || find_index_route(path) || find_up_tree_module(path) || NOT_FOUND end
def find_up_tree_module(path)
def find_up_tree_module(path) parent_path = path_parent(path) return nil if !parent_path entry = @routes[parent_path] return entry if entry && entry[:handle_subtree] find_up_tree_module(parent_path) end
def get_route_entry(path, use_cache: true)
def get_route_entry(path, use_cache: true) if use_cache cached = @cache[path] return cached if cached end entry = find_route_entry(path) set_cache(path, entry) if use_cache && entry[:kind] != :not_found entry end
def handle_added_file(fn)
def handle_added_file(fn) add_route(fn) @cache.clear # TODO: remove only relevant cache entries end
def handle_changed_file(event, fn)
def handle_changed_file(event, fn) @opts[:logger]&.call("Detected changed file: #{event} #{fn}") @module_loader&.invalidate(fn) case event when :added handle_added_file(fn) when :removed handle_removed_file(fn) when :modified handle_modified_file(fn) end end
def handle_modified_file(fn)
def handle_modified_file(fn) entry = @files[fn] if entry && entry[:kind] == :module # invalidate the entry proc, so it will be recalculated entry[:proc] = nil end end
def handle_removed_file(fn)
def handle_removed_file(fn) entry = @files[fn] if entry remove_entry_cache_keys(entry) @routes.delete(entry[:canonical_path]) @files.delete(fn) end end
def hook_wrap_if_exists(hook_fn, proc)
def hook_wrap_if_exists(hook_fn, proc) return proc if !File.file?(hook_fn) ref = path_rel(hook_fn).gsub(/\.rb$/, '') hook_proc = @module_loader.load(ref) ->(req) { hook_proc.(req, proc) } end
def initialize(opts, module_loader = nil)
def initialize(opts, module_loader = nil) raise 'Invalid location given' if !File.directory?(opts[:location]) @opts = opts @machine = opts[:machine] @root = File.expand_path(opts[:location]) @mount_path = opts[:mount_path] || '/' @rel_path_re ||= /^#{@root}/ @module_loader = module_loader @cache = {} # maps url path to route entry @routes = {} # maps canonical path to route entry (actual routes) @files = {} # maps filename to entry @deps = {} # maps filenames to array of dependent entries @x = {} # maps directories to hook chains scan_routes end
def path_abs(path, base)
def path_abs(path, base) File.join(base, path) end
def path_canonical(rel_path, kind)
def path_canonical(rel_path, kind) clean = path_clean(rel_path, kind) clean.empty? ? @mount_path : File.join(@mount_path, clean) end
def path_clean(rel_path, kind)
def path_clean(rel_path, kind) if (m = rel_path.match(INDEX_RE)) return m[1] end case kind when :static rel_path when :markdown rel_path.gsub(MD_EXT_RE, '') when :module rel_path.gsub(RB_EXT_RE, '') end end
def path_parent(path)
def path_parent(path) return nil if path == '/' m = path.match(PATH_PARENT_RE) m && (m[1] || '/') end
def path_rel(path)
def path_rel(path) path.gsub(@rel_path_re, '') end
def remove_entry_cache_keys(entry)
def remove_entry_cache_keys(entry) entry[:cache_keys]&.each_key { @cache.delete(it) }.clear end
def route_kind(fn)
def route_kind(fn) case File.extname(fn) when '.md' :markdown when '.rb' :module else :static end end
def scan_routes(dir = nil)
def scan_routes(dir = nil) dir ||= @root Dir[File.join(dir, '*')].each do basename = File.basename(it) next if (basename =~ HIDDEN_RE) File.directory?(it) ? scan_routes(it) : add_route(it) end end
def set_cache(path, entry)
def set_cache(path, entry) @cache[path] = entry (entry[:cache_keys] ||= {})[path] = true end
def start_file_watcher
def start_file_watcher @opts[:logger]&.call('Watching for file changes...', nil) @machine.spin { file_watcher_loop } end