Where do I begin? Well...
+Where do I begin? Well... First, x is {{ page.x }}.
``` -In both cases, the output is, of course: +If you have a file like `feed.xml` that you wish to _not_ be contained within a layout, specify `layout: none` in the header for the file: ```html -Where do I begin? Well...
-``` +layout: none -If you have a file like `feed.xml` that you wish to _not_ be contained within a layout, specify `layout: none` in the header for the file. + + +``` # Publishing drafts -To publish a draft, either do so through the admin interface available with `serif admin`, or add a `publish: now` header to the draft: +To automatically publish a draft, add a `publish: now` header to the draft: ``` title: A draft that will be published @@ -316,8 +278,6 @@ Created: 2013-01-01T12:01:30+00:00 Updated: 2013-03-18T19:03:30+00:00 ``` -Admin users: this is all done for you. - # Archive pages By default, archive pages are made available at `/archive/:year/month`, e.g., `/archive/2012/11`. Individual archive pages can be customised by editing the `_templates/archive_page.html` file, which is used for each month. @@ -330,7 +290,7 @@ To disable archive pages, or configure the URL format, see the section on config To link to archive pages, there is a `site.archive` template variable available in all pages. The structure of `site.archive` is a nested map starting at years: -``` +```ruby { "posts" => [...], "years" => [ @@ -353,18 +313,13 @@ Using `site.archive`, you can iterate over `years`, then iterate over `months` a Configuration goes in `_config.yml` and must be valid YAML. Here's a sample configuration with available options: -``` -admin: - username: myusername - password: mypassword +```yaml permalink: /posts/:title archive: enabled: yes url_format: /archive/:year/:month ``` -`admin` contains the credentials used when accessing the admin interface. This information is private, of course. - `permalink` is the URL format for individual post pages. The default permalink value is `/:title`. For an explanation of the format of permalinks, see above. `archive` contains configuration options concerning archive pages. `enabled` can be used to toggle whether archive pages are generated. If set to `no` or `false`, no archive pages will be generated. By default, this value is `yes`. @@ -393,23 +348,6 @@ location @not_found_page { Use `ENV=production serif generate` to regenerate the site for production. -## Admin interface - -The admin server can be started on the live server the same way it's started locally (with `ENV=production`). To access it from anywhere on the web, you will need to proxy/forward `/admin` HTTP requests to port 4567 to let the admin web server handle it. As an alternative, you could forward a local port with SSH --- you might use this if you didn't want to rely on just HTTP basic auth, which isn't very secure over non-HTTPS connections. - -# Customising the admin interface - -The admin interface is intended to be a minimal place to focus on writing content. You are free to customise the admin interface by creating a stylesheet at `$your_site_directory/css/admin/admin.css`. As an example, if your main site's stylesheet is `/css/style.css`, you can use an `@import` rule to inherit the look-and-feel of your main site editing content and looking at rendered previews. - - -```css -/* Import the main site's CSS to provide a similar look-and-feel for the admin interface */ - -@import url("/css/style.css"); - -/* more customisation below */ -``` - # Custom tags and filters These tags can be used in templates, in addition to the [standard Liquid filters and tags](https://github.com/Shopify/liquid/wiki/Liquid-for-Designers). For example: @@ -424,11 +362,6 @@ These tags can be used in templates, in addition to the [standard Liquid filters ## List of filters -* `date` with `'now'` - - This is a standard filter, but there is a [workaround](https://github.com/Shopify/liquid/pull/117) for - `{{ 'now' | date: "%Y" }}` to work, so you can use this in templates. - * `markdown` e.g., `{{ post.content | markdown }}`. @@ -527,9 +460,8 @@ Variable | Value ## Broad outline -* `./bin/serif {dev,admin,generate}` to run Serif commands. -* `rake test` to run the tests. -* Unit tests are written in RSpec. +* `./bin/serif {dev,generate}` to run Serif commands. +* `rspec` to run the tests. * `rake docs` will generate HTML documentation in `docs/`. Open `docs/index.html` in a browser to start. ## Directory structure @@ -540,12 +472,3 @@ Variable | Value # Changes and what's new See `CHANGELOG`. - -# Planned features - -Some things I'm hoping to implement one day: - -1. Custom hooks to fire after particular events, such as minifying CSS after publish, or committing changes and pushing to a git repository. -2. Simple Markdown pages instead of plain HTML for non-post content. -3. Automatically detecting file changes and regenerating the site. -4. Adding custom Liquid filters and tags. diff --git a/lib/serif.rb b/lib/serif.rb index 99b36af..cad2c67 100644 --- a/lib/serif.rb +++ b/lib/serif.rb @@ -1,24 +1,14 @@ -require "time" - -require "liquid" -require "kramdown" -require "rubypants" -require "rouge" -require "redhead" -require "timeout_cache" - -require "cgi" -require "digest" - -require "securerandom" - require "serif/errors" +require "serif/config" require "serif/content_file" -require "serif/post" require "serif/draft" +require "serif/filters" +require "serif/generator" require "serif/markup_renderer" +require "serif/page" +require "serif/placeholder" +require "serif/post" require "serif/site" -require "serif/config" module Serif end diff --git a/lib/serif/admin_server.rb b/lib/serif/admin_server.rb deleted file mode 100755 index 2414773..0000000 --- a/lib/serif/admin_server.rb +++ /dev/null @@ -1,292 +0,0 @@ -require "sinatra/base" -require "fileutils" -require "nokogiri" -require "reverse_markdown" - -module Serif -class AdminServer - class AdminApp < Sinatra::Base - Tilt.register :html, Tilt[:liquid] - - - set :root, Dir.pwd - set :public_folder, settings.root + (ENV["ENV"] == "production" ? "/_site" : "") - set :views, File.expand_path(File.join(File.dirname(__FILE__), "..", "..", "statics", "templates", "admin")) - - site = Serif::Site.new(settings.root) - - use(Rack::Auth::Basic, "Login credentials") do |username, password| - [username, password] == [site.config.admin_username, site.config.admin_password] - end - - before do - @conflicts = site.conflicts - end - - # multiple public folders?? - get "/admin/js/:file" do |file| - assets_dir = File.join(File.dirname(__FILE__), "..", "..", "statics", "assets", "js") - - [200, { "Content-Type" => "text/javascript" }, File.read(File.join(assets_dir, file))] - end - - get "/" do - redirect to("/admin") - end - - get "/admin/?" do - posts = site.posts.sort_by { |p| p.created }.reverse - drafts = site.drafts.sort_by { |p| File.mtime(p.path) }.reverse - - liquid :index, locals: { conflicts: @conflicts, posts: posts, drafts: drafts } - end - - get "/admin/edit/?" do - redirect to("/admin"), 301 - end - - get "/admin/bookmarks" do - liquid :bookmarks, locals: { conflicts: @conflicts } - end - - get "/admin/quick-draft" do - url = params[:url] - html_content = params[:content].strip - - title = params[:title] - - # delete anything nonprintable - title = title.gsub(/[^\x20-\x7E]/, "") - - # sanitise the HTML title into something we can use as a temporary slug - slug = title.split(" ").first(5).join(" ").gsub(/[^\w-]/, "-").gsub(/--+/, '-').gsub(/^-|-$/, '') - slug.downcase! - - if html_content.empty? - markdown = "[#{title}](#{url.gsub(")", "\\)")})" - else - # parse the document fragment and remove any empty nodes. - document = Nokogiri::HTML::DocumentFragment.parse(html_content) - document.traverse { |p| p.remove if p.text && p.text.strip.empty? } - html_content = document.to_html - - html_content = "#{html_content}" - markdown = ReverseMarkdown.convert(html_content, github_flavored: true, unknown_tags: :bypass) - - # markdown URLs need to have any )s escaped - markdown = "[#{title}](#{url.gsub(")", "\\)")}):\n\n#{markdown}" - end - - draft = Draft.new(site) - draft.title = title - draft.slug = slug - - # if the draft itself has no conflict, save it, - # otherwise show the new draft page with an error. - # - # if, after saving the draft because of no conflict, - # there is actually an overall site conflict, then - # keep on trucking so it doesn't interrupt the user. - if site.conflicts(draft) - liquid :new_draft, locals: { draft_content: draft.content, conflicts: @conflicts, images_path: site.config.image_upload_path.gsub(/"/, '\"'), post: draft, error_message: "There is a conflict on this draft." } - else - draft.save(markdown) - - begin - site.generate - rescue PostConflictError => e - puts "Site has conflicts, skipping generation for now." - end - - if params[:edit] == "1" - redirect to("/admin/edit/drafts/#{slug}") - else - redirect to(url) - end - end - end - - get "/admin/new/draft" do - content = Draft.new(site) - autofocus = "slug" - liquid :new_draft, locals: { draft_content: content.content.to_s, conflicts: @conflicts, images_path: site.config.image_upload_path.gsub(/"/, '\"'), post: content, autofocus: autofocus } - end - - post "/admin/new/draft" do - content = Draft.new(site) - content.slug = params[:slug].strip - content.title = params[:title].strip - - if params[:markdown].strip.empty? || params[:title].empty? || params[:slug].empty? - [:title, :slug, :markdown].each do |p| - params[p] = nil if params[p] && params[p].empty? - end - - error_message = "There must be a URL, a title, and content to save." - - autofocus = "markdown" unless params[:markdown] - autofocus = "title" unless params[:title] - autofocus = "slug" unless params[:slug] - - liquid :new_draft, locals: { draft_content: params[:markdown].to_s, conflicts: @conflicts, images_path: site.config.image_upload_path.gsub(/"/, '\"'), error_message: error_message, post: content, autofocus: autofocus } - else - if Draft.exist?(site, params[:slug]) - error_message = "Draft already eixsts with the given slug #{params[:slug]}." - liquid :new_draft, locals: { draft_content: params[:markdown].to_s, conflicts: @conflicts, images_path: site.config.image_upload_path.gsub(/"/, '\"'), error_message: error_message, post: content, autofocus: autofocus } - else - content.save(params[:markdown]) - begin - site.generate - rescue PostConflictError => e - puts "Cannot generate. Skipping for now." - end - redirect to("/admin") - end - end - end - - post "/admin/edit/drafts" do - content = Draft.from_slug(site, params[:original_slug]) - - params[:markdown] = params[:markdown].strip - - # check if the slug has been edited, i.e., if we're renaming. - if !params[:slug].empty? && params[:original_slug] && params[:original_slug] != params[:slug] - if Draft.exist?(site, params[:slug]) - conflicting_name = true - - # we need to re-edit, so reload but use the original slug name - # not the new one that was attempted to be saved. - content = Draft.from_slug(site, params[:original_slug]) - else - Draft.rename(site, params[:original_slug], params[:slug]) - - # re-load after the rename - content = Draft.from_slug(site, params[:slug]) - end - end - - # make sure the title is whatever was just submitted - content.title = params[:title] - - # any errors - if conflicting_name || params[:markdown].empty? || params[:slug].empty? - if conflicting_name - error_message = "This name is already being used for a draft." - elsif params[:markdown].empty? - error_message = "Content must not be blank." - elsif params[:slug].empty? - error_message = "You must pick a URL to use" - end - - liquid :edit_draft, locals: { draft_content: params[:markdown].to_s, conflicts: @conflicts, images_path: site.config.image_upload_path.gsub(/"/, '\"'), error_message: error_message, post: content, private_url: site.private_url(content) } - elsif (conflicts = site.conflicts) - error_message = "The site has a conflict and cannot be generated." - liquid :edit_draft, locals: { draft_content: params[:markdown].to_s, conflicts: @conflicts, images_path: site.config.image_upload_path.gsub(/"/, '\"'), error_message: error_message, post: content, private_url: site.private_url(content) } - else - content.save(params[:markdown]) - - # TODO: move the entire notion of generating a site out into - # a directory-change-level event. - if params[:publish] == "yes" - content.publish! - end - - site.generate - - redirect to("/admin") - end - end - - post "/admin/edit/posts" do - content = Post.from_basename(site, params[:original_basename]) - - params[:markdown] = params[:markdown].strip - params[:title] = params[:title].strip - - content.title = params[:title] - - if params[:markdown].empty? || params[:title].empty? - error_message = "Content must not be blank." if params[:markdown].empty? - error_message = "Title must not be blank." if params[:title].empty? - - liquid :edit_post, locals: { conflicts: @conflicts, images_path: site.config.image_upload_path.gsub(/"/, '\"'), error_message: error_message, post: content } - elsif (conflicts = site.conflicts) - error_message = "The site has a conflict and cannot be generated." - liquid :edit_post, locals: { conflicts: @conflicts, images_path: site.config.image_upload_path.gsub(/"/, '\"'), error_message: error_message, post: content } - else - content.save(params[:markdown]) - site.generate - - redirect to("/admin") - end - end - - get "/admin/edit/posts/:basename" do - redirect to("/admin") unless params[:basename] - - content = Post.from_basename(site, params[:basename]) - liquid :edit_post, locals: { conflicts: @conflicts, images_path: site.config.image_upload_path.gsub(/"/, '\"'), post: content, autofocus: "markdown" } - end - - get "/admin/edit/drafts/:slug" do - redirect to("/admin") unless params[:slug] - - content = Draft.from_slug(site, params[:slug]) - liquid :edit_draft, locals: { draft_content: content.content, conflicts: @conflicts, images_path: site.config.image_upload_path.gsub(/"/, '\"'), post: content, autofocus: "markdown", private_url: site.private_url(content) } - end - - post "/admin/delete/?" do - content = Draft.from_slug(site, params[:original_slug]) - content.delete! - - redirect to("/admin") - end - - post "/admin/convert-markdown/?" do - content = params["content"] - - if request.xhr? - Serif::Markdown.render(content).strip - end - end - - post "/admin/attachment" do - attachment = params["attachment"] - filename = attachment["final_name"] - file = attachment["file"] - uid = attachment["uid"] - - tempfile = file[:tempfile] - - FileUtils.mkdir_p(File.join(site.directory, File.dirname(filename))) - FileUtils.mkdir_p(File.dirname(site.site_path(filename))) - - source_file = File.join(site.directory, filename) - deployed_file = site.site_path(filename) - - # move to the source directory - FileUtils.mv(tempfile.path, source_file) - - # copy to production to avoid the need to generate right now - FileUtils.copy(source_file, deployed_file) - - # no executable permissions, and whatever the umask is - perms = 0777 & ~0111 & ~File.umask - File.chmod(perms, source_file, deployed_file) - - "File uploaded" - end - end - - def initialize(source_directory) - @source_directory = File.expand_path(source_directory) - end - - def start - FileUtils.cd @source_directory - app = Sinatra.new(AdminApp) - app.run! - end -end -end diff --git a/lib/serif/commands.rb b/lib/serif/commands.rb index 7fab245..8b53065 100644 --- a/lib/serif/commands.rb +++ b/lib/serif/commands.rb @@ -3,99 +3,89 @@ require "serif/server" module Serif -class Commands - def initialize(argv) - @argv = argv.dup - end + class Commands + def initialize(argv) + @argv = argv.dup + end - def process - command = @argv.shift - case command - when "-h", "--help", nil - print_help - exit 0 - when "admin" - initialize_admin_server(Dir.pwd) - when "generate" - generate_site(Dir.pwd) - when "dev" - initialize_dev_server(Dir.pwd) - when "new" - produce_skeleton(Dir.pwd) + def process + command = @argv.shift + + case command + when "-h", "--help", nil + print_help + exit 0 + when "generate" + generate_site + exit 0 + when "dev" + initialize_dev_server + when "new" + setup_new_site + + puts + puts "New site created! Generating site for the first time into _site/" + puts + + generate_site + + puts + puts "Site generated." + exit 0 + else + abort "Unknown command: #{command}" + end end - end - def initialize_admin_server(source_dir) - # need to cd to the directory before requiring the admin - # server, because otherwise Dir.pwd won't be right when - # the admin server class is defined at require time. - FileUtils.cd(source_dir) - require "serif/admin_server" + def initialize_dev_server + server = Serif::DevelopmentServer.new(Dir.pwd) + server.start + end - server = Serif::AdminServer.new(source_dir) - server.start - end + def generate_site + site = Serif::Site.new(Dir.pwd) + + begin + site.generate + rescue Serif::PostConflictError => e + puts "Error! Unable to generate because there is a conflict." + puts + puts "Conflicts at:" + puts + + site.conflicts.each do |url, ary| + puts url + ary.each do |e| + puts "\t#{e.path}" + end + end - def initialize_dev_server(source_dir) - FileUtils.cd(source_dir) + exit 1 + end + end - server = Serif::DevelopmentServer.new(source_dir) - server.start - end + def setup_new_site + target_dir = Dir.pwd - def generate_site(source_dir) + if Dir[File.join(target_dir, "*")].length > 0 + abort "Directory is not empty." + end - site = Serif::Site.new(source_dir) + skeleton_dir = File.join(File.dirname(__FILE__), "..", "..", "site_template") - begin - site.generate - rescue Serif::PostConflictError => e - puts "Error! Unable to generate because there is a conflict." - puts - puts "Conflicts at:" - puts + puts "Creating _posts" + FileUtils.mkdir(File.join(target_dir, "_posts")) - site.conflicts.each do |url, ary| - puts url - ary.each do |e| - puts "\t#{e.path}" + Dir.chdir(skeleton_dir) do + Dir["*"].each do |f| + puts "Creating #{f}" + FileUtils.cp_r(f, target_dir, verbose: false) end end - - exit 1 - end - end - - def verify_directory(dir) - unless Dir.exist?(dir) - puts "No such directory: #{dir}'" - exit 1 end - end - - def produce_skeleton(dir) - if !Dir[File.join(dir, "*")].empty? - abort "Directory is not empty." - end - - FileUtils.cd(File.join(File.dirname(__FILE__), "..", "..", "statics", "skeleton")) - files = Dir["*"] - files.each do |f| - FileUtils.cp_r(f, dir, verbose: true) - end - FileUtils.mkdir(File.join(dir, "_posts")) - - generate_site(dir) - - puts - puts "*** NOTE ***" - puts - puts "You should now edit the username and password in _config.yml" - puts - end - def print_help - puts <<-END_HELP + def print_help + puts <<-END_HELP USAGE serif [-h | --help] @@ -112,8 +102,6 @@ def print_help serif new Create a site skeleton to get started. Will only run if the current directory is empty. - serif admin Start the admin server on localhost:4567. - serif dev Start a simple dev server on localhost:8000. Serves up the generated static files, but loads some files (like CSS) from source (instead of @@ -126,14 +114,8 @@ def print_help $ ENV=production serif generate - $ ENV=production serif admin - - Note that this by and large doesn't change much, - but in future it may provide extra features. - - The main benefit is that the `file_digest` tag - will return a hex digest of the given file's - contents only when ENV is set to production. + This causes the `file_digest` Liquid tag to + return a hex digest of the given file's contents. EXAMPLES @@ -141,11 +123,7 @@ def print_help Generate the site. - $ serif admin - - Start the admin server on localhost:4567. - END_HELP + end end end -end diff --git a/lib/serif/config.rb b/lib/serif/config.rb index a937ec2..68e438a 100644 --- a/lib/serif/config.rb +++ b/lib/serif/config.rb @@ -1,45 +1,31 @@ require "yaml" module Serif -class Config - def initialize(config_file) - @config_file = config_file - end - - def admin_username - yaml["admin"]["username"] - end - - def admin_password - yaml["admin"]["password"] - end - - def image_upload_path - yaml["image_upload_path"] || "/images/:timestamp_:name" - end + class Config + def initialize(config_file) + @config_file = config_file + end - def permalink - yaml["permalink"] || "/:title" - end + def permalink + yaml_config["permalink"] || "/:title" + end - def archive_enabled? - a = yaml["archive"] + def archive_enabled? + archive_config["enabled"] + end - if a - a["enabled"] - else - false + def archive_url_format + archive_config["url_format"] || "/archive/:year/:month" end - end - def archive_url_format - (yaml["archive"] || {})["url_format"] || "/archive/:year/:month" - end + private - private + def yaml_config + @yaml_config ||= YAML.load_file(@config_file) + end - def yaml - @yaml ||= YAML.load_file(@config_file) + def archive_config + yaml_config["archive"] || {} + end end end -end diff --git a/lib/serif/content_file.rb b/lib/serif/content_file.rb index 58727ae..3a58ff5 100644 --- a/lib/serif/content_file.rb +++ b/lib/serif/content_file.rb @@ -1,163 +1,112 @@ -# -# ContentFile represents a file on the filesystem -# which contains the contents of a post, be it in -# draft form or otherwise. -# +require "time" +require "redhead" module Serif -class ContentFile - attr_reader :path, :slug, :site - - def initialize(site, path = nil) - @site = site - @path = path - - if @path - # we have to parse out the source first so that we get necessary - # metadata like published vs. draft. - load_source - - dirname = File.basename(File.dirname(@path)) - basename = File.basename(@path) - @slug = draft? ? basename : basename.split("-")[3..-1].join("-") + class ContentFile + attr_reader :path, :site + + def self.all(site, dirname, klass) + Dir[site.source_path(dirname, "*")].select do |f| + File.file?(f) + end.map do |f| + File.expand_path(f) + end.map do |f| + klass.new(site, f) + end end - end - def basename - File.basename(@path) - end - - def slug=(str) - @slug = str - - # if we're adding a slug and there's no path yet, then create the path. - # this will run for new drafts - - @path ||= File.expand_path("#{site.directory}/#{self.class.dirname}/#{@slug}") - end + def initialize(site, path) + unless site && path + raise ArgumentError, "must provide both site and path" + end - def title - return nil if !@source - headers[:title] - end + @site = site + @path = path - def title=(new_title) - if !@source - @source = Redhead::String["title: #{new_title}\n\n"] - else - @source.headers[:title] = new_title + load_source end - @cached_headers = nil - end - - # Returns true if the file is in the directory for draft content, or - # has no saved path yet. - def draft? - return true if !path - - File.dirname(path) == File.join(site.directory, Draft.dirname) - end + def draft? + !published? + end - # Returns true if the file is in the directory for published posts, - # false otherwise. - # - # If there is no path at all, returns false. - def published? - return false if !path + def title + headers[:title] + end - File.dirname(path) == File.join(site.directory, Post.dirname) - end + def content + @source.to_s + end - def content(include_headers = false) - include_headers ? "#{@source.headers.to_s}\n\n#{@source.to_s}" : @source.to_s - end + def created + return nil unless headers[:created] - def created - return nil if !@source - headers[:created].utc - end + headers[:created].utc + end - def updated - return nil if !@source - (headers[:updated] || created).utc - end + def updated + time = headers[:updated] || created + return nil unless time - def headers - return @cached_headers if @cached_headers + time.utc + end - return (@cached_headers = {}) unless @source + def headers + @cached_headers ||= @source.headers.to_h.map do |key, value| + if key == :created || key == :updated + value = Time.parse(value) + end - headers = @source.headers - converted_headers = {} + [key, value] + end.to_h + end - headers.each do |header| - key, value = header.key, header.value + def save + set_updated_time(Time.now) - if key == :created || key == :updated - value = Time.parse(value) + File.open(path, "w") do |f| + f.puts %Q{#{@source.headers.to_s}\n\n#{@source.to_s}}.strip end - converted_headers[key] = value + load_source end - @cached_headers = converted_headers - end - - def save(markdown = nil) - markdown ||= content if @source - - save_path = path || "#{self.class.dirname}/#{@slug}" - - # TODO: when a draft is being saved, it will call set_publish_time - # and then the #save call will execute this line, which will mean - # there is a very, very slight difference (fraction of a second) - # between the update time of a brand new published post and the - # creation time. - set_updated_time(Time.now) - - File.open(save_path, "w") do |f| - f.puts %Q{#{@source.headers.to_s} - -#{markdown}}.strip + def to_liquid + headers.map { |k, v| [k.to_s, v] }.to_h.merge( + "content" => content, + "slug" => slug, + "url" => url, + "draft" => draft?, + "published" => published? + ) end - # after every save, ensure we've re-loaded the saved content - load_source - - true # always return true for now - end - - def inspect - %Q{<#{self.class} #{headers.inspect}>} - end + protected - protected + def set_updated_time(time) + @source.headers[:updated] = time.xmlschema + headers_changed! + end - def set_publish_time(time) - @source.headers[:created] = time.xmlschema - headers_changed! - end + def headers_changed! + @cached_headers = nil + end - def set_updated_time(time) - @source.headers[:updated] = time.xmlschema - headers_changed! - end + private - # Invalidates the cached headers entirely. - # - # Any methods which alter headers should call this. - def headers_changed! - @cached_headers = nil - end + def load_source + source = File.read(path).gsub(/\r?\n/, "\n") + source.force_encoding("UTF-8") + @source = Redhead::String[source] + headers_changed! + end - private + def layout + Liquid::Template.parse(File.read(site.source_path("_layouts", "#{headers[:layout] || "default"}.html"))) + end - def load_source - source = File.read(path).gsub(/\r?\n/, "\n") - source.force_encoding("UTF-8") - @source = Redhead::String[source] - @cached_headers = nil + def template + Liquid::Template.parse(File.read(site.source_path("_templates/post.html"))) + end end end -end diff --git a/lib/serif/draft.rb b/lib/serif/draft.rb index 5923f0a..ef781c3 100644 --- a/lib/serif/draft.rb +++ b/lib/serif/draft.rb @@ -1,114 +1,62 @@ module Serif -class Draft < ContentFile - attr_reader :autopublish - - def self.dirname - "_drafts" - end - - def self.rename(site, original_slug, new_slug) - raise "file exists" if File.exist?("#{site.directory}/#{dirname}/#{new_slug}") - File.rename("#{site.directory}/#{dirname}/#{original_slug}", "#{site.directory}/#{dirname}/#{new_slug}") - end - - # Returns the URL that would be used for this post if it were - # to be published now. - def url - permalink_style = headers[:permalink] || site.config.permalink - - parts = { - "title" => slug.to_s, - "year" => Time.now.year.to_s, - "month" => Time.now.month.to_s.rjust(2, "0"), - "day" => Time.now.day.to_s.rjust(2, "0") - } - - output = permalink_style - - parts.each do |placeholder, value| - output = output.gsub(Regexp.quote(":" + placeholder), value) + class Draft < ContentFile + def self.all(site) + super(site, "_drafts", self) end - output - end - - def delete! - FileUtils.mkdir_p("#{site.directory}/_trash") - File.rename(@path, File.expand_path("#{site.directory}/_trash/#{Time.now.to_i}-#{slug}")) - end + def slug + @slug ||= File.basename(path) + end - def publish! - publish_time = Time.now - date = Time.now.strftime("%Y-%m-%d") - filename = "#{date}-#{slug}" + def published? + false + end - FileUtils.mkdir_p("#{site.directory}/#{Post.dirname}") - full_published_path = File.expand_path("#{site.directory}/#{Post.dirname}/#{filename}") + def url + permalink_style = headers[:permalink] || site.config.permalink - raise "conflict, post exists already" if File.exist?(full_published_path) + parts = { + "title" => slug.to_s, + "year" => Time.now.year.to_s, + "month" => Time.now.month.to_s.rjust(2, "0"), + "day" => Time.now.day.to_s.rjust(2, "0") + } - set_publish_time(publish_time) + Serif::Placeholder.substitute(permalink_style, parts) + end - @source.headers.delete(:publish) if autopublish? + def publish! + publish_time = Time.now + date = publish_time.strftime("%Y-%m-%d") - save + FileUtils.mkdir_p(site.source_path("_posts")) - File.rename(path, full_published_path) + published_filename = "#{date}-#{slug}" + published_path = site.source_path("_posts/#{published_filename}") - # update the path since the file has now changed - @path = Post.new(site, full_published_path).path - end + if File.exist?(published_path) + raise "found a conflict when trying to publish #{published_filename}: a file with that name exists already" + end - # if the assigned value is truthy, the "publish" header - # is set to "now", otherwise the header is removed. - def autopublish=(value) - if value - @source.headers[:publish] = "now" - else + @source.headers[:created] = publish_time.xmlschema @source.headers.delete(:publish) - end - headers_changed! - end + save - # Checks the value of the "publish" header, and returns - # true if the value is "now", ignoring trailing and leading - # whitespace. Returns false, otherwise. - def autopublish? - publish_header = headers[:publish] - publish_header && publish_header.strip == "now" - end - - def to_liquid - h = { - "title" => title, - "content" => content, - "slug" => slug, - "type" => "draft", - "draft" => draft?, - "published" => published?, - "url" => url - } - - headers.each do |key, value| - h[key] = value + FileUtils.mv(path, published_path) end - h - end - - def self.exist?(site, slug) - all(site).any? { |d| d.slug == slug } - end - - def self.all(site) - files = Dir[File.join(site.directory, dirname, "*")].select { |f| File.file?(f) }.map { |f| File.expand_path(f) } - files.map { |f| new(site, f) } - end + def autopublish? + headers[:publish].to_s.strip == "now" + end - def self.from_slug(site, slug) - path = File.expand_path(File.join(site.directory, dirname, slug)) - new(site, path) + def render(site) + layout.render!( + "site" => site, + "draft_preview" => true, + "page" => { "title" => title }, + "content" => template.render!("site" => site, "post" => self, "draft_preview" => true) + ) + end end end -end diff --git a/lib/serif/errors.rb b/lib/serif/errors.rb index c6c2f86..93b17a2 100644 --- a/lib/serif/errors.rb +++ b/lib/serif/errors.rb @@ -1,10 +1,3 @@ module Serif - # General error class. Allows capturing general errors - # applicable only to Serif. - class Error < RuntimeError; end - - # Represents a conflict between published posts and drafts. - # This should be used whenever two posts would occupy the same - # URL / file path. - class PostConflictError < Error; end + class PostConflictError < StandardError; end end diff --git a/lib/serif/filters.rb b/lib/serif/filters.rb new file mode 100644 index 0000000..cca6230 --- /dev/null +++ b/lib/serif/filters.rb @@ -0,0 +1,75 @@ +require "cgi" +require "rubypants" +require "liquid" +require "time" +require "digest" + +module Serif + module Filters + def strip(input) + input.strip + end + + def encode_uri_component(string) + return "" unless string + + CGI.escape(string) + end + + def smarty(text) + RubyPants.new(text).to_html + end + + def markdown(body) + Serif::Markdown.render(body) + end + + def xmlschema(input) + input.xmlschema + end + end + + class FileDigest < Liquid::Tag + DIGEST_CACHE = {} + + # file_digest "file.css" [prefix:.] + Syntax = /^\s*(\S+)\s*(?:(prefix\s*:\s*\S+)\s*)?$/ + + def initialize(tag_name, markup, tokens) + super + + if markup =~ Syntax + @path = $1 + + if $2 + @prefix = $2.gsub(/\s*prefix\s*:\s*/, "") + else + @prefix = "" + end + else + raise SyntaxError.new("Syntax error for file_digest") + end + end + + # Takes the given path and returns the MD5 + # hex digest of the file's contents. + # + # The path argument is first stripped, and any leading + # "/" has no effect. + def render(context) + return "" unless ENV["ENV"] == "production" + + full_path = File.join(context["site"]["__directory"], @path.strip) + + return @prefix + DIGEST_CACHE[full_path] if DIGEST_CACHE[full_path] + + digest = Digest::MD5.hexdigest(File.read(full_path)) + DIGEST_CACHE[full_path] = digest + + @prefix + digest + end + end +end + +Liquid::Template.register_filter(Serif::Filters) +Liquid::Template.register_tag("file_digest", Serif::FileDigest) diff --git a/lib/serif/generator.rb b/lib/serif/generator.rb new file mode 100644 index 0000000..ecbcbc1 --- /dev/null +++ b/lib/serif/generator.rb @@ -0,0 +1,149 @@ +module Serif + class Generator + attr_reader :site + + def initialize(site) + @site = site + end + + def default_layout + Liquid::Template.parse(File.read(site.source_path("_layouts/default.html"))) + end + + def generate! + Dir.chdir(site.source_directory) do + FileUtils.rm_rf("tmp/_site") + FileUtils.mkdir_p("tmp/_site") + + if site.conflicts + raise PostConflictError, "Generating would cause a conflict." + end + + puts "Auto-publishing drafts..." + preprocess_autopublish_drafts + puts "Auto-updating posts..." + preprocess_autoupdate_posts + + puts "Processing general files..." + process_general_files + puts "Processing published posts..." + process_posts + + puts + puts "Draft previews created:" + puts generate_draft_previews.join("\n") + + if site.config.archive_enabled? + generate_archives(default_layout) + end + + update_site_path + end + end + + private + + def preprocess_autoupdate_posts + site.posts.each do |p| + if p.autoupdate? + p.update! + end + end + end + + def generate_draft_previews + site.drafts.map do |draft| + preview_path = draft_preview_path(draft) + + live_preview_file = site.tmp_path(preview_path) + FileUtils.mkdir_p(File.dirname(live_preview_file)) + + File.open(live_preview_file + ".html", "w") do |f| + f.puts draft.render(site) + end + + preview_path + end + end + + def draft_preview_path(draft) + private_draft_pattern = site.site_path("drafts/#{draft.slug}/*") + existing_file = Dir[private_draft_pattern].first + + file = existing_file ? File.basename(existing_file, ".html") : SecureRandom.hex(30) + + "drafts/#{draft.slug}/#{file}" + end + + def preprocess_autopublish_drafts + site.drafts.each do |d| + if d.autopublish? + d.publish! + end + end + end + + def process_general_files + files = Dir["**/*"].select { |f| f !~ /\A_/ && File.file?(f) } + + files.each do |path| + dirname = File.dirname(path) + filename = File.basename(path) + + FileUtils.mkdir_p(site.tmp_path(dirname)) + + if bypass?(filename) + FileUtils.cp(path, site.tmp_path(path)) + next + end + + page = Serif::Page.new(site, path) + + File.open(site.tmp_path(path), "w") do |f| + f.puts page.render + end + end + end + + def process_posts + [nil, *site.posts, nil].each_cons(3) do |next_post, post, prev_post| + FileUtils.mkdir_p(site.tmp_path(File.dirname(post.url))) + + File.open(site.tmp_path(post.url + ".html"), "w") do |f| + f.puts post.render(site, prev_post: prev_post, next_post: next_post) + end + end + end + + def generate_archives(layout) + template = Liquid::Template.parse(File.read(site.source_path("_templates/archive_page.html"))) + + months = site.posts.group_by { |post| Date.new(post.created.year, post.created.month) } + + months.each do |month, posts| + archive_path = site.tmp_path(site.archive_url_for_date(month)) + + FileUtils.mkdir_p(File.dirname(archive_path)) + + File.open(File.join(archive_path + ".html"), "w") do |f| + f.puts layout.render!( + "archive_page" => true, + "month" => month, + "site" => site, + "content" => template.render!("archive_page" => true, "site" => site, "month" => month, "posts" => posts) + ) + end + end + end + + def update_site_path + FileUtils.rm_rf(site.source_path("_site")) && + FileUtils.mv(site.source_path("tmp/_site"), site.source_directory) && + FileUtils.rm_rf(site.source_path("tmp")) + end + + def bypass?(filename) + !%w[.html .xml].include?(File.extname(filename)) + end + end +end diff --git a/lib/serif/markup_renderer.rb b/lib/serif/markup_renderer.rb index 725c3a4..a542aba 100644 --- a/lib/serif/markup_renderer.rb +++ b/lib/serif/markup_renderer.rb @@ -1,9 +1,28 @@ +require "kramdown" +require "rouge" + +module Serif + class Markdown + def self.render(markdown) + options = { + input: "GFM", + auto_id_stripping: true, + enable_coderay: false, + hard_wrap: false, + parse_block_html: false + } + Kramdown::Document.new(markdown, options).to_serif_custom + end + end +end + module Kramdown module Converter class SerifCustom < Html def convert_codeblock(el, indent) attr = el.attr.dup language = extract_code_language!(attr) + if language Rouge.highlight(el.value, language, "html") else @@ -13,18 +32,3 @@ def convert_codeblock(el, indent) end end end - -module Serif -class Markdown - def self.render(markdown) - options = { - input: "GFM", - auto_id_stripping: true, - enable_coderay: false, - hard_wrap: false, - parse_block_html: false - } - Kramdown::Document.new(markdown, options).to_serif_custom - end -end -end diff --git a/lib/serif/page.rb b/lib/serif/page.rb new file mode 100644 index 0000000..4a4d112 --- /dev/null +++ b/lib/serif/page.rb @@ -0,0 +1,48 @@ +module Serif + class Page + attr_reader :site, :path + + def initialize(site, path) + @site = site + @path = path + end + + def render + template = Liquid::Template.parse(source.to_s) + + if layout_option == "none" + return template.render!("site" => site, "page" => headers) + end + + layout.render!( + "site" => site, + "page" => headers, + "content" => template.render!("site" => site, "page" => headers) + ) + end + + private + + def source + @source ||= Redhead::String[File.read(path)] + end + + def headers + source.headers.to_h.map { |k, v| [k.to_s, v] }.to_h + end + + def title + headers["title"] + end + + def layout_option + headers["layout"] || "default" + end + + def layout + layout_file = site.source_path("_layouts", "#{layout_option}.html") + + Liquid::Template.parse(File.read(layout_file)) + end + end +end diff --git a/lib/serif/placeholder.rb b/lib/serif/placeholder.rb new file mode 100644 index 0000000..04f17f4 --- /dev/null +++ b/lib/serif/placeholder.rb @@ -0,0 +1,13 @@ +module Serif + module Placeholder + def self.substitute(input, substitutions) + output = input + + substitutions.each do |placeholder_name, value| + output = output.gsub(Regexp.quote(":" + placeholder_name), value) + end + + output + end + end +end diff --git a/lib/serif/post.rb b/lib/serif/post.rb index bb31294..5320438 100755 --- a/lib/serif/post.rb +++ b/lib/serif/post.rb @@ -1,90 +1,64 @@ require "fileutils" module Serif -class Post < ContentFile - def self.dirname - "_posts" - end - - def url - permalink_style = headers[:permalink] || site.config.permalink - - filename_parts = File.basename(path).split("-") - - parts = { - "title" => slug, - "year" => filename_parts[0], - "month" => filename_parts[1], - "day" => filename_parts[2] - } - - output = permalink_style - - parts.each do |placeholder, value| - output = output.gsub(Regexp.quote(":" + placeholder), value) + class Post < ContentFile + def self.all(site) + super(site, "_posts", self) end - output - end + def slug + @slug ||= File.basename(path).split("-")[3..-1].join("-") + end - # if the assigned value is truthy, the "update" header - # is set to "now", otherwise the header is removed. - def autoupdate=(value) - if value - @source.headers[:update] = "now" - else - @source.headers.delete(:update) + def published? + true end - headers_changed! - end + def url + permalink_style = headers[:permalink] || site.config.permalink - # returns true if the post has been marked as needing a - # new updated timestamp header. - # - # this is based on the presence of an "update: now" header. - def autoupdate? - update_header = headers[:update] - update_header && update_header.strip == "now" - end + filename_parts = File.basename(path).split("-") - # Updates the updated timestamp and saves the contents. - # - # If there is an "update" header (see autoupdate?), it is deleted. - def update! - @source.headers.delete(:update) - set_updated_time(Time.now) - save - end + parts = { + "title" => slug, + "year" => filename_parts[0], + "month" => filename_parts[1], + "day" => filename_parts[2] + } - def self.all(site) - files = Dir[File.join(site.directory, dirname, "*")].select { |f| File.file?(f) }.map { |f| File.expand_path(f) } - files.map { |f| new(site, f) } - end + Serif::Placeholder.substitute(permalink_style, parts) + end - def self.from_basename(site, filename) - all(site).find { |p| p.basename == filename } - end + def autoupdate? + headers[:update].to_s.strip == "now" + end - def to_liquid - h = { - "title" => title, - "created" => created, - "updated" => updated, - "content" => content, - "slug" => slug, - "url" => url, - "type" => "post", - "draft" => draft?, - "published" => published?, - "basename" => basename - } + def update! + @source.headers.delete(:update) + save + end - headers.each do |key, value| - h[key.to_s] = value + def to_liquid + super.merge( + "created" => created, + "updated" => updated, + ) end - h + def render(site, prev_post:, next_post:) + template_variables = { + "post" => self, + "post_page" => true, + "prev_post" => prev_post, + "next_post" => next_post + } + + layout.render!( + "site" => site, + "page" => { "title" => title }, + "post_page" => true, + "content" => template.render!(template_variables) + ) + end end end -end diff --git a/lib/serif/server.rb b/lib/serif/server.rb index c0d4cc9..385a99c 100644 --- a/lib/serif/server.rb +++ b/lib/serif/server.rb @@ -1,42 +1,44 @@ require "sinatra/base" -require "fileutils" module Serif -class DevelopmentServer - class DevApp < Sinatra::Base - set :public_folder, Dir.pwd + class DevelopmentServer + class DevApp < Sinatra::Base + set :public_folder, Dir.pwd - not_found { "Resource not found" } + not_found { "Resource not found" } - get "/" do - File.read(File.expand_path("_site/index.html")) - end + get "/" do + File.read(File.expand_path("_site/index.html")) + end - # it seems Rack::Rewrite doesn't like public_folder files, so here we are - get "*" do - # attempt the exact name + an extension - file = Dir[File.expand_path("_site#{params[:splat].join("/")}.*")].first + # it seems Rack::Rewrite doesn't like public_folder files, so here we are + get "*" do + # attempt the exact name + an extension + file = Dir[File.expand_path("_site#{params[:splat].join("/")}.*")].first - # try index.html under the directory if it failed. useful for archive directory requests. - file ||= Dir[File.expand_path("_site#{params[:splat].join("/")}/index.html")].first + # try index.html under the directory if it failed. useful for archive directory requests. + file ||= Dir[File.expand_path("_site#{params[:splat].join("/")}/index.html")].first - # make a naive assumption that there's a 404 file at 404.html - file ||= Dir[File.expand_path("_site/404.html")].first + # make a naive assumption that there's a 404 file at 404.html + file ||= Dir[File.expand_path("_site/404.html")].first - file ? File.read(file) : 404 + file ? File.read(file) : 404 + end end - end - attr_reader :source_directory + attr_reader :source_directory - def initialize(source_directory) - @source_directory = source_directory - end + def initialize(source_directory) + raise ArgumentError, "a source directory must be given" unless source_directory - def start - FileUtils.cd @source_directory - app = Sinatra.new(DevApp) - app.run!(:port => 8000) + @source_directory = source_directory + end + + def start + Dir.chdir(source_directory) do + app = Sinatra.new(DevApp) + app.run!(port: 8000) + end + end end end -end diff --git a/lib/serif/site.rb b/lib/serif/site.rb index a07b623..bed5772 100644 --- a/lib/serif/site.rb +++ b/lib/serif/site.rb @@ -1,497 +1,111 @@ -module Serif -module Filters - def strip(input) - input.strip - end - - def encode_uri_component(string) - return "" unless string - CGI.escape(string) - end - - def smarty(text) - RubyPants.new(text).to_html - end - - def markdown(body) - Serif::Markdown.render(body) - end - - def xmlschema(input) - input.xmlschema - end -end - -class FileDigest < Liquid::Tag - DIGEST_CACHE = {} - - # file_digest "file.css" [prefix:.] - Syntax = /^\s*(\S+)\s*(?:(prefix\s*:\s*\S+)\s*)?$/ - - def initialize(tag_name, markup, tokens) - super - - if markup =~ Syntax - @path = $1 - - if $2 - @prefix = $2.gsub(/\s*prefix\s*:\s*/, "") - else - @prefix = "" - end - else - raise SyntaxError.new("Syntax error for file_digest") - end - end - - # Takes the given path and returns the MD5 - # hex digest of the file's contents. - # - # The path argument is first stripped, and any leading - # "/" has no effect. - def render(context) - return "" unless ENV["ENV"] == "production" - - full_path = File.join(context["site"]["directory"], @path.strip) - - return @prefix + DIGEST_CACHE[full_path] if DIGEST_CACHE[full_path] - - digest = Digest::MD5.hexdigest(File.read(full_path)) - DIGEST_CACHE[full_path] = digest - - @prefix + digest - end -end -end - -Liquid::Template.register_filter(Serif::Filters) -Liquid::Template.register_tag("file_digest", Serif::FileDigest) +require "liquid" +require "time" +require "fileutils" +require "securerandom" module Serif -class Site - def initialize(source_directory) - @source_directory = source_directory - end + class Site + attr_reader :source_directory - def directory - @source_directory - end - - # Returns all of the site's posts, in reverse chronological order - # by creation time. - def posts - Post.all(self).sort_by { |entry| entry.created }.reverse - end + def initialize(directory) + raise ArgumentError, "a source directory must be given" unless directory - def drafts - Draft.all(self) - end - - def config - @config ||= Serif::Config.new(File.join(@source_directory, "_config.yml")) - end - - def site_path(path) - File.join("_site", path) - end - - def tmp_path(path) - File.join("tmp", site_path(path)) - end - - def latest_update_time - most_recent = posts.max_by { |p| p.updated } - most_recent ? most_recent.updated : Time.now - end - - # Gives the URL absolute path to a private draft preview. - # - # If the draft has no such preview available, returns nil. - def private_url(draft) - private_draft_pattern = site_path("/drafts/#{draft.slug}/*") - file = Dir[private_draft_pattern].first - - return nil unless file - - "/drafts/#{draft.slug}/#{File.basename(file, ".html")}" - end - - def bypass?(filename) - !%w[.html .xml].include?(File.extname(filename)) - end - - # Returns the relative archive URL for the given date, - # using the value of config.archive_url_format - def archive_url_for_date(date) - format = config.archive_url_format - - parts = { - "year" => date.year.to_s, - "month" => date.month.to_s.rjust(2, "0") - } - - output = format - - parts.each do |placeholder, value| - output = output.gsub(Regexp.quote(":" + placeholder), value) + @source_directory = directory end - output - end - - # Returns a nested hash with the following structure: - # - # { - # :posts => [], - # :years => [ - # { - # :date => Date.new(2012), - # :posts => [], - # :months => [ - # { :date => Date.new(2012, 12), :archive_url => "/archive/2012/12", :posts => [] }, - # { :date => Date.new(2012, 11), :archive_url => "/archive/2012/11", :posts => [] }, - # # ... - # ] - # }, - # - # # ... - # ] - # } - def archives - h = {} - h[:posts] = posts - - # group posts by Date instances for the first day of the year - year_groups = posts.group_by { |post| Date.new(post.created.year) }.to_a - - # collect all elements as maps for the year start date and the posts in that year - year_groups.map! do |year_start_date, posts_by_year| - { - :date => year_start_date, - :posts => posts_by_year.sort_by { |post| post.created }.reverse - } + def pages + Page.all(self) end - year_groups.sort_by! { |year_hash| year_hash[:date] } - year_groups.reverse! - - year_groups.each do |year_hash| - year_posts = year_hash[:posts] - - # group the posts in the year by month - month_groups = year_posts.group_by { |post| Date.new(post.created.year, post.created.month) }.to_a - - # collect the elements as maps for the month start date and the posts in that month - month_groups.map! do |month_start_date, posts_by_month| - { - :date => month_start_date, - :posts => posts_by_month.sort_by { |post| post.created }.reverse, - :archive_url => archive_url_for_date(month_start_date) - } - end - - month_groups.sort_by! { |month_hash| month_hash[:date] } - month_groups.reverse! - - # set the months for the current year - year_hash[:months] = month_groups + def posts + Post.all(self).sort_by { |entry| entry.created }.reverse end - h[:years] = year_groups - - # return the final hash - h - end - - # Returns a hash representing any conflicting URLs, - # in the form - # - # { "/url_1" => [e_1, e_2, ..., e_n], ... } - # - # The elements e_i are the conflicting Post and - # Draft instances that share the URL "/url_1". - # - # Note that if n = 1 (that is, the array value is - # [e_1], containing a single element), then it is - # not included in the Hash, since it does not - # contribute to a conflict. - # - # If there are no conflicts found, returns nil. - # - # If an argument is specified, its #url value is - # compared against all post and draft URLs, and - # the value returned is either: - # - # 1. an array of post/draft instances that - # conflict, _including_ the argument given; or, - # 2. nil if there is no conflict. - def conflicts(content_to_check = nil) - if content_to_check - content = drafts + posts + [content_to_check] - - # In the event that the given argument is actually one of the - # drafts + posts, we need to de-duplicate, otherwise our return - # value will contain two of the same Draft/Post, which isn't - # actually a conflict. - # - # So to do that, we can use the path on the filesystem. However, - # we can't just rely on calling #path, because if content_to_check - # doesn't have a #path value, it'll be nil and it's possible that - # we might expand checking to multiple files/Drafts/Posts. - # - # Thus, if #path is nil, simply rely on object_id. - # - # FIXME: Replace this with a proper implementation of - # ContentFile equality/hashing. - content.uniq! { |e| e.path ? e.path : e.object_id } - - conflicts = content.select { |e| e.url == content_to_check.url } - - if conflicts.length <= 1 - return nil - else - return conflicts - end + def drafts + Draft.all(self) end - conflicts = (drafts + posts).group_by { |e| e.url } - conflicts.reject! { |k, v| v.length == 1 } - - if conflicts.empty? - nil - else - conflicts + def config + @config ||= Serif::Config.new(source_path("_config.yml")) end - end - - def to_liquid - @liquid_cache_store ||= TimeoutCache.new(1) - - cached_value = @liquid_cache_store[:liquid] - return cached_value if cached_value - @liquid_cache_store[:liquid] = { - "posts" => posts, - "latest_update_time" => latest_update_time, - "archive" => self.class.stringify_keys(archives), - "directory" => directory - } - end - - def generate - FileUtils.cd(@source_directory) - - FileUtils.rm_rf("tmp/_site") - FileUtils.mkdir_p("tmp/_site") - - files = Dir["**/*"].select { |f| f !~ /\A_/ && File.file?(f) } - - default_layout = Liquid::Template.parse(File.read("_layouts/default.html")) - - if conflicts - raise PostConflictError, "Generating would cause a conflict." + def source_path(*path) + File.join(source_directory, *path) end - # preprocess any drafts marked for autopublish, before grabbing the posts - # to operate on. - preprocess_autopublish_drafts - - # preprocess any posts that might have had an update flag set in the header - preprocess_autoupdate_posts - - posts = self.posts - - files.each do |path| - puts "Processing file: #{path}" - - dirname = File.dirname(path) - filename = File.basename(path) - - FileUtils.mkdir_p(tmp_path(dirname)) - if bypass?(filename) - FileUtils.cp(path, tmp_path(path)) - else - File.open(tmp_path(path), "w") do |f| - file = File.read(path) - title = nil - layout_option = :default - - if Redhead::String.has_headers?(file) - file_with_headers = Redhead::String[file] - title = file_with_headers.headers[:title] && file_with_headers.headers[:title].value - layout_option = file_with_headers.headers[:layout] && file_with_headers.headers[:layout].value - layout_option ||= :default - - # all good? use the headered string - file = file_with_headers - end - - if layout_option == "none" - f.puts Liquid::Template.parse(file.to_s).render!("site" => self) - else - if layout_option == :default - layout = default_layout - else - layout_file = File.join(self.directory, "_layouts", "#{layout_option}.html") - layout = Liquid::Template.parse(File.read(layout_file)) - end - f.puts layout.render!("site" => self, "page" => { "title" => title }, "content" => Liquid::Template.parse(file.to_s).render!("site" => self)) - end - end - end + def site_path(path) + source_path("_site", path) end - # run through the posts + nil so we can keep |a, b| such that a hits every element - # while iterating. - posts.each.with_index do |post, i| - # the posts are iterated over in reverse chrological order, and - # next_post here is post published chronologically after than - # the post in the iteration. - # - # if i == 0, we don't want posts.last, so return nil if i - 1 == -1 - next_post = (i == 0 ? nil : posts[i - 1]) - prev_post = posts[i + 1] - - puts "Processing post: #{post.path}" - - FileUtils.mkdir_p(tmp_path(File.dirname(post.url))) - - post_layout = default_layout - - if post.headers[:layout] - post_layout = Liquid::Template.parse(File.read(File.join(self.directory, "_layouts", "#{post.headers[:layout]}.html"))) - end - - File.open(tmp_path(post.url + ".html"), "w") do |f| - # variables available in the post template - post_template_variables = { - "post" => post, - "post_page" => true, - "prev_post" => prev_post, - "next_post" => next_post - } - - f.puts post_layout.render!( - "site" => self, - "page" => { "title" => post.title }, - "post_page" => true, - "content" => Liquid::Template.parse(File.read("_templates/post.html")).render!(post_template_variables) - ) - end + def tmp_path(path) + File.join("tmp", File.join("_site", path)) end - generate_draft_previews(default_layout) - - generate_archives(default_layout) - - if Dir.exist?("_site") - FileUtils.mv("_site", "/tmp/_site.#{Time.now.strftime("%Y-%m-%d-%H-%M-%S.%6N")}") + def latest_update_time + most_recent = posts.max_by { |p| p.updated } + most_recent ? most_recent.updated : Time.now end - FileUtils.mv("tmp/_site", ".") && FileUtils.rm_rf("tmp/_site") - FileUtils.rmdir("tmp") - Dir["/tmp/_site.*"][0..-6].each { |d| FileUtils.rm_rf(d) } - end - - private + def archive_url_for_date(date) + parts = { + "year" => date.year.to_s, + "month" => date.month.to_s.rjust(2, "0") + } - def preprocess_autoupdate_posts - posts.each do |p| - if p.autoupdate? - puts "Auto-updating timestamp for: #{p.title} / #{p.slug}" - p.update! - end + Serif::Placeholder.substitute(config.archive_url_format, parts) end - end - # generates draft preview files for any unpublished drafts. - # - # uses the same template as live posts. - def generate_draft_previews(layout) - drafts = self.drafts + def archives + h = { + "posts" => posts, + "years" => posts.group_by { |p| Date.new(p.created.year) }.map do |year_date, year_posts| + year_posts = year_posts.sort_by(&:created).reverse - template = Liquid::Template.parse(File.read("_templates/post.html")) + { + "date" => year_date, + "posts" => year_posts, + "months" => year_posts.group_by { |p| Date.new(p.created.year, p.created.month) }.to_a.map do |month_date, month_posts| + month_posts = month_posts.sort_by(&:created).reverse - # publish each draft under a randomly generated name, or use the - # existing file if one is present. - drafts.each do |draft| - url = private_url(draft) - if url - # take our existing URL like /drafts/foo/
some markdown
" + ], + smarty: [ + %Q~{{ "testing's for a " | append: '"' | append: "heading's" | append: '"' | append: " `with code` in it..." | smarty }}~, "testing’s for a “heading’s” `with code` in it…" + ], + strip: [ + %Q~{{ " testing " | strip }}~, "testing" + ], + xmlschema: [ + %Q~{{ site.latest_update_time | xmlschema }}~, "2400-01-01T00:00:00Z" + ], + encode_uri_component: [ + %Q~{{ "x&y" | encode_uri_component }}~, "x%26y" + ], + file_digest: [ + %Q~{% file_digest test-stylesheet.css %}~, "f8390232f0c354a871f9ba0ed306163c" + ] + }.each do |tag, (input, expected_output)| + describe "| #{tag}" do + it "is supported in non-post pages, in the layout and the file" do + with_file_contents(testing_dir("_layouts/test--with-#{tag}.html"), "Testing's span testing
")).to eq("Testing’s span testing
") + expect(subject.strip(input)).to eq("result") end end describe "#encode_uri_component" do it "percent-encodes various characters for use in a URI" do { + # ambiguous cases, tested here to ensure we're talking about ?query=params. + # http://blade.nagaokaut.ac.jp/cgi-bin/scat.rb/ruby/ruby-core/29373 " " => "+", "!" => "%21", "$" => "%24", @@ -64,30 +48,36 @@ end end - describe "#xmlschema" do - it "calls xmlschema on its input" do - d = double("") - expect(d).to receive(:xmlschema).once - subject.xmlschema(d) + describe "#smarty" do + it "runs the input through a smartypants processor" do + expect(subject.smarty("Testing")).to eq("Testing") + expect(subject.smarty("Testing's")).to eq("Testing’s") + expect(subject.smarty(%q{"Testing" some "text's" input...})).to eq("“Testing” some “text’s” input…") + end - t = Time.parse("2012-01-01") - t_utc = Time.utc("2012-01-01") - # -0400 => -04:00 - offset = t.strftime("%z").gsub(/(\d\d)\z/, ':\1') + it "does not do any markdown processing" do + expect(subject.smarty("# Heading")).to eq("# Heading") + expect(subject.smarty("Testing `code blocks` input")).to eq("Testing `code blocks` input") + end - expect(subject.xmlschema(t)).to eq("2012-01-01T00:00:00#{offset}") - expect(subject.xmlschema(t_utc)).to eq("2012-01-01T00:00:00Z") + it "deals with HTML appropriately" do + expect(subject.smarty("Testing's span testing
")).to eq("Testing’s span testing
") end end describe "#markdown" do - it "processes its input as markdown" do - # bit of a stub test - expect(subject.markdown("# Hi!").strip).to eq(%{post_page flag set for layout
{% endif %} - -{{ content }} diff --git a/spec/site_dir/_posts/2012-01-05-sample-post b/spec/site_dir/_posts/2012-01-05-sample-post deleted file mode 100644 index 42d923f..0000000 --- a/spec/site_dir/_posts/2012-01-05-sample-post +++ /dev/null @@ -1,4 +0,0 @@ -title: Sample post -Created: 2012-11-21T17:07:09+00:00 - -Just a sample post. diff --git a/spec/site_dir/_posts/2013-01-01-second-post b/spec/site_dir/_posts/2013-01-01-second-post deleted file mode 100644 index a9d3d70..0000000 --- a/spec/site_dir/_posts/2013-01-01-second-post +++ /dev/null @@ -1,4 +0,0 @@ -title: Second post -Created: 2013-01-01T00:00:00+00:00 - -Second post. diff --git a/spec/site_dir/_posts/2013-03-07-post-with-custom-layout b/spec/site_dir/_posts/2013-03-07-post-with-custom-layout deleted file mode 100644 index 42d0e7a..0000000 --- a/spec/site_dir/_posts/2013-03-07-post-with-custom-layout +++ /dev/null @@ -1,5 +0,0 @@ -title: Custom layout -Created: 2013-03-07T00:00:00+00:00 -layout: alt-layout - -Second post. diff --git a/spec/site_dir/_posts/2399-01-01-penultimate-post b/spec/site_dir/_posts/2399-01-01-penultimate-post deleted file mode 100644 index d362491..0000000 --- a/spec/site_dir/_posts/2399-01-01-penultimate-post +++ /dev/null @@ -1,4 +0,0 @@ -title: Penultimate post -Created: 2399-01-01T00:00:00+00:00 - -Penultimate post diff --git a/spec/site_dir/_posts/2400-01-01-final-post b/spec/site_dir/_posts/2400-01-01-final-post deleted file mode 100644 index 2139f8e..0000000 --- a/spec/site_dir/_posts/2400-01-01-final-post +++ /dev/null @@ -1,4 +0,0 @@ -title: Final post -Created: 2400-01-01T00:00:00+00:00 - -The final post in the blog diff --git a/spec/site_dir/page-alt-layout.html b/spec/site_dir/page-alt-layout.html deleted file mode 100644 index 0dfde39..0000000 --- a/spec/site_dir/page-alt-layout.html +++ /dev/null @@ -1,3 +0,0 @@ -layout: alt-layout - -page alt layout diff --git a/spec/site_dir/test-smarty-filter.html b/spec/site_dir/test-smarty-filter.html deleted file mode 100644 index ddf897e..0000000 --- a/spec/site_dir/test-smarty-filter.html +++ /dev/null @@ -1,3 +0,0 @@ -Some content - -{{ "testing's for a " | append: '"' | append: "heading's" | append: '"' | append: " `with code` in it..." | smarty }} diff --git a/spec/site_generation_spec.rb b/spec/site_generation_spec.rb deleted file mode 100644 index 1ee9497..0000000 --- a/spec/site_generation_spec.rb +++ /dev/null @@ -1,202 +0,0 @@ -RSpec.describe Serif::Site do - subject do - Serif::Site.new(testing_dir) - end - - before(:each) do - FileUtils.rm_rf(testing_dir("_site")) - end - - describe "site generation" do - it "raises PostConflictError if there are conflicts" do - # not nil, the value is unimportant - allow(subject).to receive(:conflicts) { [] } - expect { capture_stdout { subject.generate } }.to raise_error(Serif::PostConflictError) - end - - it "uses the permalinks in the config file for site generation" do - capture_stdout { subject.generate } - expect(File.exist?(testing_dir("_site/test-blog/sample-post.html"))).to be_truthy - end - - it "reads the layout header for a non-post file and uses the appropriate layout file" do - capture_stdout { subject.generate } - - # check it actually got generated - expect(File.exist?(testing_dir("_site/page-alt-layout.html"))).to be_truthy - expect(File.read("_site/page-alt-layout.html").lines.first).to match(/post_page flag set for layout
{% endif %} + +{{ content }}draftpreviewflagexists
{% endif %} +{% if draft_preview %}this is a draft preview
{% endif %} {% if post_page %}post_page flag set for template
{% endif %}This page should be rendered without the post_page flag set to false.
diff --git a/spec/test_source/test--page-with-a-custom-layout-header-value.html b/spec/test_source/test--page-with-a-custom-layout-header-value.html new file mode 100644 index 0000000..03568b1 --- /dev/null +++ b/spec/test_source/test--page-with-a-custom-layout-header-value.html @@ -0,0 +1,3 @@ +layout: alt-layout + +This page should use the custom layout header value specified above. diff --git a/spec/site_dir/file-digest-test.html b/spec/test_source/test--page-with-a-filedigest-filter.html similarity index 71% rename from spec/site_dir/file-digest-test.html rename to spec/test_source/test--page-with-a-filedigest-filter.html index d3eefcf..5bd1284 100644 --- a/spec/site_dir/file-digest-test.html +++ b/spec/test_source/test--page-with-a-filedigest-filter.html @@ -1,4 +1,5 @@ layout: none {% file_digest test-stylesheet.css %} +{% file_digest /test-stylesheet.css %} {% file_digest test-stylesheet.css prefix:. %} diff --git a/spec/site_dir/test-stylesheet.css b/spec/test_source/test-stylesheet.css similarity index 100% rename from spec/site_dir/test-stylesheet.css rename to spec/test_source/test-stylesheet.css diff --git a/statics/assets/js/attachment.js b/statics/assets/js/attachment.js deleted file mode 100644 index b1723c0..0000000 --- a/statics/assets/js/attachment.js +++ /dev/null @@ -1,124 +0,0 @@ -// Derived by Adam Prescott from a code snippet used -// with implied permission: -// -// http://blog.alexmaccaw.com/svbtle-image-uploading - -var createAttachment = function(file, element) { - var data = new FormData(); - - var d = new Date(); - var uid = d.getTime(); - - var month = d.getMonth().toString(); - if (month.length < 2) { month = "0" + month; } - - var day = d.getDate().toString(); - if (day.length < 2) { day = "0" + day; } - - var processedName = file.name.replace(/[^a-zA-Z0-9_]/g, "_").replace(/__+/g, "_"); - - var s = Serif.variables["imageUploadPattern"]; - - if (/:slug/.test(s) && !(Serif.variables["currentSlug"] && Serif.variables["currentSlug"])) { - alert("Your image upload path is set to use a slug, but no such slug exists yet."); - return null; - } - - var placeholderValues = { - ":slug": Serif.variables["currentSlug"], - ":timestamp": uid.toString(), - ":year": d.getFullYear().toString(), - ":month": month, - ":day": day, - ":name": processedName - }; - - $.each(placeholderValues, function(placeholder, value) { - s = s.replace(placeholder, value); - }); - - var extension = file.name.substring(file.name.lastIndexOf('.') + 1); - - var finalName = s; - - // if it doesn't already have the extension in the name, add it, - // otherwise, correct the, e.g., _png, to .png. - // - // this just avoids _png.png noise - if (finalName.substring(finalName.length - extension.length - 1) == ("_" + extension)) { - finalName = finalName.replace(new RegExp("_" + extension + "$"), "." + extension); - } else { - finalName = finalName + "." + extension; - } - - data.append('attachment[file]', file); - data.append('attachment[uid]', uid); - data.append('attachment[final_name]', finalName); - - $.ajax({ - url: '/admin/attachment', - data: data, - cache: false, - contentType: false, - processData: false, - type: 'POST', - }).error(function() { - console.log("error uploading image"); - }); - - var absText = '![' + file.name + '](' + finalName + ')'; - - // for some reason this is necessary to avoid the following - // giving an undefined result: - // - // 1. load a new draft page with an empty textarea - // 2. at this point element.value is undefined. - // 3. drag an image, calling insertAtCaret - // 4. element.value (3) in this method is still undefined (confusing!) - // 5. in the browser console, $("[data-attachify]").get(0).value - // is correct. (confusing!) - // 6. on a _second drag_, element.value is undefined (very confusing) - // 7. in the same second drag event, $(element).get(0).value is - // correct, hence this "reloading" to allow element.value - // below to not be undefined. - element = $(element).get(0); - - if (typeof element.value != "undefined") { - var pos = element.selectionStart; - var text = element.value; - var before = text.slice(0, pos); - var after = text.slice(pos); - - // if there is only a single newline, add one more for a blank - // line. - if (/[^\n]\n$/.test(before)) { - absText = "\n" + absText; - // if there aren't two new lines, add a full two - } else if (! /\n\n$/.test(before)) { - absText = "\n\n" + absText; - } - - } - - $(element).insertAtCaret(absText); -}; - -$(function() { - if ($("[data-attachify]").length > 0) { - $(document).dropArea(); - - $(document).bind("drop", function(e) { - e.preventDefault(); - e = e.originalEvent; - - var files = e.dataTransfer.files; - - for (var i=0; i < files.length; i++) { - // Only upload images - if (/image/.test(files[i].type)) { - createAttachment(files[i], $("[data-attachify]").first()); - } - }; - }); - } -}); diff --git a/statics/assets/js/jquery.autosize.js b/statics/assets/js/jquery.autosize.js deleted file mode 100644 index 3900476..0000000 --- a/statics/assets/js/jquery.autosize.js +++ /dev/null @@ -1,175 +0,0 @@ -// Autosize 1.13 - jQuery plugin for textareas -// (c) 2012 Jack Moore - jacklmoore.com -// license: www.opensource.org/licenses/mit-license.php - -(function ($) { - var - hidden = 'hidden', - borderBox = 'border-box', - lineHeight = 'lineHeight', - copy = '', - // line-height is omitted because IE7/IE8 doesn't return the correct value. - copyStyle = [ - 'fontFamily', - 'fontSize', - 'fontWeight', - 'fontStyle', - 'letterSpacing', - 'textTransform', - 'wordSpacing', - 'textIndent' - ], - oninput = 'oninput', - onpropertychange = 'onpropertychange', - test = $(copy)[0]; - - // For testing support in old FireFox - test.setAttribute(oninput, "return"); - - if ($.isFunction(test[oninput]) || onpropertychange in test) { - - // test that line-height can be accurately copied to avoid - // incorrect value reporting in old IE and old Opera - $(test).css(lineHeight, '99px'); - if ($(test).css(lineHeight) === '99px') { - copyStyle.push(lineHeight); - } - - $.fn.autosize = function (options) { - options = options || {}; - - return this.each(function () { - var - ta = this, - $ta = $(ta), - mirror, - minHeight = $ta.height(), - maxHeight = parseInt($ta.css('maxHeight'), 10), - active, - i = copyStyle.length, - resize, - boxOffset = 0, - value = ta.value, - callback = $.isFunction(options.callback); - - if ($ta.css('box-sizing') === borderBox || $ta.css('-moz-box-sizing') === borderBox || $ta.css('-webkit-box-sizing') === borderBox){ - boxOffset = $ta.outerHeight() - $ta.height(); - } - - if ($ta.data('mirror') || $ta.data('ismirror')) { - // if autosize has already been applied, exit. - // if autosize is being applied to a mirror element, exit. - return; - } else { - mirror = $(copy).data('ismirror', true).addClass(options.className || 'autosizejs')[0]; - - resize = $ta.css('resize') === 'none' ? 'none' : 'horizontal'; - - $ta.data('mirror', $(mirror)).css({ - overflow: hidden, - overflowY: hidden, - wordWrap: 'break-word', - resize: resize - }); - } - - // Opera returns '-1px' when max-height is set to 'none'. - maxHeight = maxHeight && maxHeight > 0 ? maxHeight : 9e4; - - // Using mainly bare JS in this function because it is going - // to fire very often while typing, and needs to very efficient. - function adjust() { - var height, overflow, original; - - // the active flag keeps IE from tripping all over itself. Otherwise - // actions in the adjust function will cause IE to call adjust again. - if (!active) { - active = true; - mirror.value = ta.value; - mirror.style.overflowY = ta.style.overflowY; - original = parseInt(ta.style.height,10); - - // Update the width in case the original textarea width has changed - mirror.style.width = $ta.css('width'); - - // Needed for IE to reliably return the correct scrollHeight - mirror.scrollTop = 0; - - // Set a very high value for scrollTop to be sure the - // mirror is scrolled all the way to the bottom. - mirror.scrollTop = 9e4; - - height = mirror.scrollTop; - overflow = hidden; - if (height > maxHeight) { - height = maxHeight; - overflow = 'scroll'; - } else if (height < minHeight) { - height = minHeight; - } - height += boxOffset; - ta.style.overflowY = overflow; - - if (original !== height) { - ta.style.height = height + 'px'; - if (callback) { - options.callback.call(ta); - } - } - - // This small timeout gives IE a chance to draw it's scrollbar - // before adjust can be run again (prevents an infinite loop). - setTimeout(function () { - active = false; - }, 1); - } - } - - // mirror is a duplicate textarea located off-screen that - // is automatically updated to contain the same text as the - // original textarea. mirror always has a height of 0. - // This gives a cross-browser supported way getting the actual - // height of the text, through the scrollTop property. - while (i--) { - mirror.style[copyStyle[i]] = $ta.css(copyStyle[i]); - } - - $('body').append(mirror); - - if (onpropertychange in ta) { - if (oninput in ta) { - // Detects IE9. IE9 does not fire onpropertychange or oninput for deletions, - // so binding to onkeyup to catch most of those occassions. There is no way that I - // know of to detect something like 'cut' in IE9. - ta[oninput] = ta.onkeyup = adjust; - } else { - // IE7 / IE8 - ta[onpropertychange] = adjust; - } - } else { - // Modern Browsers - ta[oninput] = adjust; - - // The textarea overflow is now hidden. But Chrome doesn't reflow the text after the scrollbars are removed. - // This is a hack to get Chrome to reflow it's text. - ta.value = ''; - ta.value = value; - } - - $(window).resize(adjust); - - // Allow for manual triggering if needed. - $ta.bind('autosize', adjust); - - // Call adjust in case the textarea already contains text. - adjust(); - }); - }; - } else { - // Makes no changes for older browsers (FireFox3- and Safari4-) - $.fn.autosize = function (callback) { - return this; - }; - } - -}(jQuery)); \ No newline at end of file diff --git a/statics/assets/js/jquery.drop.js b/statics/assets/js/jquery.drop.js deleted file mode 100644 index 69fbcb8..0000000 --- a/statics/assets/js/jquery.drop.js +++ /dev/null @@ -1,33 +0,0 @@ -// All credit to Alex MacCaw: -// -// https://gist.github.com/maccman/2907187 - -(function($){ - function dragEnter(e) { - $(e.target).addClass("dragOver"); - e.stopPropagation(); - e.preventDefault(); - return false; - }; - - function dragOver(e) { - e.originalEvent.dataTransfer.dropEffect = "copy"; - e.stopPropagation(); - e.preventDefault(); - return false; - }; - - function dragLeave(e) { - $(e.target).removeClass("dragOver"); - e.stopPropagation(); - e.preventDefault(); - return false; - }; - - $.fn.dropArea = function(){ - this.bind("dragenter", dragEnter). - bind("dragover", dragOver). - bind("dragleave", dragLeave); - return this; - }; -})(jQuery); \ No newline at end of file diff --git a/statics/assets/js/jquery.insert.js b/statics/assets/js/jquery.insert.js deleted file mode 100644 index 6455777..0000000 --- a/statics/assets/js/jquery.insert.js +++ /dev/null @@ -1,39 +0,0 @@ -// All credit to Alex MacCaw -// -// https://gist.github.com/maccman/2907189 - -(function($){ - var insertAtCaret = function(value) { - if (document.selection) { // IE - this.focus(); - sel = document.selection.createRange(); - sel.text = value; - this.focus(); - } - else if (this.selectionStart || this.selectionStart == '0') { - var startPos = this.selectionStart; - var endPos = this.selectionEnd; - var scrollTop = this.scrollTop; - - this.value = [ - this.value.substring(0, startPos), - value, - this.value.substring(endPos, this.value.length) - ].join(''); - - this.focus(); - this.selectionStart = startPos + value.length; - this.selectionEnd = startPos + value.length; - this.scrollTop = scrollTop; - - } else { - throw new Error('insertAtCaret not supported'); - } - }; - - $.fn.insertAtCaret = function(value){ - $(this).each(function(){ - insertAtCaret.call(this, value); - }) - }; -})(jQuery); \ No newline at end of file diff --git a/statics/assets/js/mousetrap.min.js b/statics/assets/js/mousetrap.min.js deleted file mode 100644 index 9333e82..0000000 --- a/statics/assets/js/mousetrap.min.js +++ /dev/null @@ -1,28 +0,0 @@ -/** - * Copyright 2012 Craig Campbell - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - * - * Mousetrap is a simple keyboard shortcut library for Javascript with - * no external dependencies - * - * @version 1.1.4 - * @url craig.is/killing/mice - */ -(function(){function q(a,c,b){if(a.addEventListener)return a.addEventListener(c,b,!1);a.attachEvent("on"+c,b)}function w(a){return"keypress"==a.type?String.fromCharCode(a.which):h[a.which]?h[a.which]:x[a.which]?x[a.which]:String.fromCharCode(a.which).toLowerCase()}function r(a){var a=a||{},c=!1,b;for(b in l)a[b]?c=!0:l[b]=0;c||(n=!1)}function y(a,c,b,d,F){var g,e,f=[],i=b.type;if(!k[a])return[];"keyup"==i&&s(a)&&(c=[a]);for(g=0;gDrag these links to your bookmarks and use them to create new drafts.
- -Any selected text will go in as Markdown, ready for editing!
- - - -Saves the current page as a draft. Takes you straight back to where you were so you can keep reading.
- - - -Saves the current page as a draft and lets you start editing immediately.
-{{ error_message }}
-{{ error_message }}
-(Most recently modified first.)
- -(Most recently published first.)
- -The following URLs have conflicts and must be resolved:
-Ctrl-/ / Command-/ |
- Show/hide keyboard shortcut |
g h |
- Go to the admin homepage |
g n |
- Go to the new draft page |
ESC / Ctrl-[ / Command-[ |
- Remove any <input> focus |
Ctrl-. / Command-. |
- Toggle focused view |
Ctrl-S / Command-S |
- Save |
Ctrl-U / Command-U |
- Jump to URL name |
Ctrl-H / Command-H |
- Jump to title/heading |
Ctrl-Enter / Command-Enter |
- Toggle rendered preview |
{{ error_message }}
-