feat: initial release of volumen — a file-based Markdown blog engine
This commit is contained in:
@@ -0,0 +1,104 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require "fileutils"
|
||||
require "securerandom"
|
||||
require_relative "post"
|
||||
|
||||
module Volumen
|
||||
# Reads and writes Markdown posts in a content directory.
|
||||
#
|
||||
# Posts live either at `content_dir/<slug>.md` (default language) or at
|
||||
# `content_dir/<lang>/<slug>.md` (per-language subdirectory). Missing
|
||||
# `slug`/`lang` frontmatter is derived from the file path.
|
||||
class Store
|
||||
attr_reader :content_dir
|
||||
|
||||
def initialize(content_dir, default_lang: nil)
|
||||
@content_dir = File.expand_path(content_dir.to_s)
|
||||
@default_lang = default_lang
|
||||
end
|
||||
|
||||
def all
|
||||
markdown_files.filter_map { |path| load_post(path) }
|
||||
end
|
||||
|
||||
def find(slug, lang: nil)
|
||||
all.find do |post|
|
||||
post.slug == slug && (lang.nil? || post.lang == lang || post.all_langs?)
|
||||
end
|
||||
end
|
||||
|
||||
def save(post)
|
||||
target = post.path || default_path_for(post)
|
||||
FileUtils.mkdir_p(File.dirname(target))
|
||||
File.write(target, post.to_file)
|
||||
post.path = target
|
||||
post
|
||||
end
|
||||
|
||||
def delete(slug, lang: nil)
|
||||
post = find(slug, lang: lang)
|
||||
File.delete(post.path) if post&.path
|
||||
post
|
||||
end
|
||||
|
||||
def media_dir
|
||||
File.join(@content_dir, "media")
|
||||
end
|
||||
|
||||
def store_upload(original_name, source)
|
||||
FileUtils.mkdir_p(media_dir)
|
||||
name = safe_media_name(original_name)
|
||||
File.open(File.join(media_dir, name), "wb") { |file| IO.copy_stream(source, file) }
|
||||
"/media/#{name}"
|
||||
end
|
||||
|
||||
def media_file(name)
|
||||
path = File.join(media_dir, File.basename(name.to_s))
|
||||
return nil unless File.file?(path) && within?(media_dir, path)
|
||||
|
||||
path
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def markdown_files
|
||||
Dir.glob(File.join(@content_dir, "**", "*.md"))
|
||||
end
|
||||
|
||||
def load_post(path)
|
||||
post = Post.parse(File.read(path))
|
||||
post.metadata["slug"] ||= File.basename(path, ".md")
|
||||
post.metadata["lang"] ||= lang_from_path(path) || @default_lang
|
||||
post.path = path
|
||||
post
|
||||
rescue StandardError => e
|
||||
warn "volumen: skipping #{path}: #{e.message}"
|
||||
nil
|
||||
end
|
||||
|
||||
def lang_from_path(path)
|
||||
parent = File.dirname(File.expand_path(path))
|
||||
return nil if parent == @content_dir
|
||||
|
||||
File.basename(parent)
|
||||
end
|
||||
|
||||
def default_path_for(post)
|
||||
File.join(@content_dir, "#{post.slug}.md")
|
||||
end
|
||||
|
||||
def safe_media_name(original)
|
||||
base = File.basename(original.to_s)
|
||||
ext = File.extname(base).gsub(/[^a-zA-Z0-9.]/, "").downcase
|
||||
stem = File.basename(base, File.extname(base))
|
||||
.gsub(/[^a-zA-Z0-9_-]+/, "-").gsub(/-+/, "-").downcase
|
||||
stem = "file" if stem.empty?
|
||||
"#{SecureRandom.hex(4)}-#{stem}#{ext}"
|
||||
end
|
||||
|
||||
def within?(dir, path)
|
||||
File.expand_path(path).start_with?(File.expand_path(dir) + File::SEPARATOR)
|
||||
end
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user