├── .gitignore ├── .ruby-version ├── Gemfile ├── Gemfile.lock ├── LICENSE ├── Procfile ├── README.md ├── Rakefile ├── app.rb ├── config.ru ├── db └── migrate │ ├── 001_create_sites.rb │ ├── 002_create_stores.rb │ ├── 003_create_flows.rb │ ├── 004_add_flow_media_templates.rb │ ├── 005_update_flow_type_to_kind.rb │ ├── 006_create_log.rb │ ├── 007_add_file_flow_to_sites.rb │ ├── 008_add_settings_to_sites.rb │ ├── 009_add_file_to_log.rb │ └── 010_add_photos_to_log.rb ├── lib ├── _transformative │ ├── authorship.rb │ ├── context.rb │ ├── media_s3.rb │ ├── notification.rb │ ├── syndication.rb │ ├── twitter.rb │ ├── utils.rb │ ├── view_helper.rb │ └── webmention.rb ├── auth.rb ├── generators.yml ├── init.rb ├── media.rb ├── micropub.rb ├── post.rb ├── post_types.yml └── posts │ ├── _entry.rb │ ├── article.rb │ ├── audio.rb │ ├── bookmark.rb │ ├── checkin.rb │ ├── event.rb │ ├── like.rb │ ├── note.rb │ ├── photo.rb │ ├── reply.rb │ ├── repost.rb │ └── video.rb ├── models ├── file_system.rb ├── flow.rb ├── github.rb ├── init.rb ├── site.rb └── store.rb ├── public ├── css │ ├── codemirror.css │ ├── milligram.css │ ├── normalize.css │ └── styles.css ├── favicon.ico ├── images │ ├── article.svg │ ├── attachment.svg │ ├── audio.svg │ ├── bookmark.svg │ ├── checkin.svg │ ├── event.svg │ ├── file.svg │ ├── like.svg │ ├── note.svg │ ├── photo.svg │ ├── reply.svg │ ├── repost.svg │ ├── setup.svg │ └── video.svg └── js │ └── codemirror │ ├── codemirror.js │ ├── markdown.js │ ├── multiplex.js │ ├── yaml-frontmatter.js │ └── yaml.js ├── routes ├── init.rb ├── manage.rb ├── micropub.rb ├── pages.rb └── webmention.rb ├── script ├── console ├── migrate └── server └── views ├── 404.erb ├── 500.erb ├── flow_edit.erb ├── flow_media.erb ├── gallery.erb ├── index.erb ├── layout.erb ├── site_posting.erb ├── site_settings.erb ├── site_status.erb ├── site_uploading.erb ├── store_edit.erb ├── syslog.erb └── tools.erb /.gitignore: -------------------------------------------------------------------------------- 1 | # Used by dotenv library to load environment variables. 2 | .env 3 | -------------------------------------------------------------------------------- /.ruby-version: -------------------------------------------------------------------------------- 1 | 2.6.5 2 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | ruby '2.6.5' 4 | 5 | gem 'rake', ">= 2.0.6" 6 | gem 'sinatra', require: 'sinatra/base' 7 | gem 'sinatra-contrib', require: 'sinatra/link_header' 8 | gem 'rack-contrib' 9 | gem 'rack-ssl' 10 | gem 'moneta' 11 | gem 'redis' 12 | gem 'puma' 13 | gem 'httparty' 14 | gem 'nokogiri' 15 | gem 'mustache' 16 | gem 'mustermann' 17 | gem 'octokit' 18 | gem 'microformats' 19 | gem 'redcarpet' 20 | gem 'sanitize' 21 | gem 'builder' 22 | gem 'webmention' 23 | gem 'sequel_pg', require: 'sequel' 24 | gem 'will_paginate' 25 | gem 's3' 26 | gem 'twitter' 27 | gem 'tzinfo' 28 | gem 'tzinfo-data' 29 | 30 | group :production do 31 | gem 'sentry-raven' 32 | end 33 | 34 | group :development do 35 | gem 'dotenv' 36 | gem 'shotgun' 37 | end 38 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | GEM 2 | remote: https://rubygems.org/ 3 | specs: 4 | addressable (2.7.0) 5 | public_suffix (>= 2.0.2, < 5.0) 6 | backports (3.15.0) 7 | buftok (0.2.0) 8 | builder (3.2.4) 9 | concurrent-ruby (1.1.5) 10 | crass (1.0.6) 11 | domain_name (0.5.20190701) 12 | unf (>= 0.0.5, < 1.0.0) 13 | dotenv (2.7.5) 14 | equalizer (0.0.11) 15 | faraday (0.17.1) 16 | multipart-post (>= 1.2, < 3) 17 | http (3.3.0) 18 | addressable (~> 2.3) 19 | http-cookie (~> 1.0) 20 | http-form_data (~> 2.0) 21 | http_parser.rb (~> 0.6.0) 22 | http-cookie (1.0.3) 23 | domain_name (~> 0.5) 24 | http-form_data (2.1.1) 25 | http_parser.rb (0.6.0) 26 | httparty (0.15.7) 27 | multi_xml (>= 0.5.2) 28 | json (2.3.0) 29 | link_header (0.0.8) 30 | memoizable (0.4.2) 31 | thread_safe (~> 0.3, >= 0.3.1) 32 | microformats (4.1.0) 33 | json (~> 2.1) 34 | nokogiri (~> 1.8, >= 1.8.3) 35 | mini_portile2 (2.4.0) 36 | moneta (1.2.1) 37 | multi_json (1.14.1) 38 | multi_xml (0.6.0) 39 | multipart-post (2.1.1) 40 | mustache (1.1.1) 41 | mustermann (1.0.3) 42 | naught (1.1.0) 43 | nio4r (2.5.2) 44 | nokogiri (1.10.8) 45 | mini_portile2 (~> 2.4.0) 46 | nokogumbo (2.0.2) 47 | nokogiri (~> 1.8, >= 1.8.4) 48 | octokit (4.14.0) 49 | sawyer (~> 0.8.0, >= 0.5.3) 50 | pg (1.1.4) 51 | proxies (0.2.3) 52 | public_suffix (4.0.1) 53 | puma (4.3.5) 54 | nio4r (~> 2.0) 55 | rack (2.2.3) 56 | rack-contrib (2.1.0) 57 | rack (~> 2.0) 58 | rack-protection (2.0.7) 59 | rack 60 | rack-ssl (1.4.1) 61 | rack 62 | rake (13.0.1) 63 | redcarpet (3.5.0) 64 | redis (4.1.3) 65 | s3 (0.3.28) 66 | addressable 67 | proxies 68 | sanitize (5.2.1) 69 | crass (~> 1.0.2) 70 | nokogiri (>= 1.8.0) 71 | nokogumbo (~> 2.0) 72 | sawyer (0.8.2) 73 | addressable (>= 2.3.5) 74 | faraday (> 0.8, < 2.0) 75 | sentry-raven (2.13.0) 76 | faraday (>= 0.7.6, < 1.0) 77 | sequel (5.27.0) 78 | sequel_pg (1.12.2) 79 | pg (>= 0.18.0) 80 | sequel (>= 4.38.0) 81 | shotgun (0.9.2) 82 | rack (>= 1.0) 83 | simple_oauth (0.3.1) 84 | sinatra (2.0.7) 85 | mustermann (~> 1.0) 86 | rack (~> 2.0) 87 | rack-protection (= 2.0.7) 88 | tilt (~> 2.0) 89 | sinatra-contrib (2.0.7) 90 | backports (>= 2.8.2) 91 | multi_json 92 | mustermann (~> 1.0) 93 | rack-protection (= 2.0.7) 94 | sinatra (= 2.0.7) 95 | tilt (~> 2.0) 96 | thread_safe (0.3.6) 97 | tilt (2.0.10) 98 | twitter (6.2.0) 99 | addressable (~> 2.3) 100 | buftok (~> 0.2.0) 101 | equalizer (~> 0.0.11) 102 | http (~> 3.0) 103 | http-form_data (~> 2.0) 104 | http_parser.rb (~> 0.6.0) 105 | memoizable (~> 0.4.0) 106 | multipart-post (~> 2.0) 107 | naught (~> 1.0) 108 | simple_oauth (~> 0.3.0) 109 | tzinfo (2.0.0) 110 | concurrent-ruby (~> 1.0) 111 | tzinfo-data (1.2019.3) 112 | tzinfo (>= 1.0.0) 113 | unf (0.1.4) 114 | unf_ext 115 | unf_ext (0.0.7.6) 116 | webmention (0.1.6) 117 | httparty (~> 0.15.5) 118 | json 119 | link_header (~> 0.0.8) 120 | nokogiri 121 | will_paginate (3.2.1) 122 | 123 | PLATFORMS 124 | ruby 125 | 126 | DEPENDENCIES 127 | builder 128 | dotenv 129 | httparty 130 | microformats 131 | moneta 132 | mustache 133 | mustermann 134 | nokogiri 135 | octokit 136 | puma 137 | rack-contrib 138 | rack-ssl 139 | rake (>= 2.0.6) 140 | redcarpet 141 | redis 142 | s3 143 | sanitize 144 | sentry-raven 145 | sequel_pg 146 | shotgun 147 | sinatra 148 | sinatra-contrib 149 | twitter 150 | tzinfo 151 | tzinfo-data 152 | webmention 153 | will_paginate 154 | 155 | RUBY VERSION 156 | ruby 2.6.5p114 157 | 158 | BUNDLED WITH 159 | 1.17.2 160 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Barry Frost 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | release: script/migrate 2 | web: rackup -s puma -p $PORT 3 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Sitewriter 2 | 3 | This is a small Sinatra service that provides publishing endpoints for sites that cannot provide their own. 4 | 5 | The first use case is Micropub posting of notes to [my Middleman site](https://hans.gerwitz.com/) via GitHub. 6 | 7 | Essentially, a configured domain has some _endpoints_, which receive content and apply _templates_ which are then sent to a _store_. 8 | 9 | Authentication for configuration is provided via IndieAuth.com 10 | 11 | I also use IndieAuth for the Micropub tokens. If you'd like to do the same, set the environment variable `TOKEN_ENDPOINT` to `https://tokens.indieauth.com/token` 12 | 13 | ## Post type heuristics 14 | 15 | - If `mp-type` is defined, use it 16 | - If `type` == h-event or `h` == event: event 17 | - If `type` == h-entry or `h` == entry: 18 | - If there is a `in-reply-to` value: reply 19 | - quotation 20 | - If there is a `repost-of` value: repost 21 | - If there is a `bookmark-of` value: bookmark 22 | - If there is a `checkin` or `u-checkin` value: checkin 23 | - If there is a `like-of` value: like 24 | - If there is a `video` value: video 25 | - If there is a `photo` value: photo 26 | - If there is a `audio` value: audio 27 | - If there is a `name` value: article 28 | - Otherwise: note 29 | 30 | N.B. entries are flat. The value of a property may be an entry itself, but the "deeper" values will not be flattened for post type discovery. 31 | 32 | ## Credits 33 | 34 | This was inspired by and initiated as a fork of Barry Frost's [Transformative](https://github.com/barryf/transformative). 35 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require "bundler/setup" 2 | Bundler.require(:default, :development) 3 | 4 | # require 'json' 5 | # require 'time' 6 | 7 | if ENV['RACK_ENV'] != 'production' 8 | # require 'dotenv' 9 | Dotenv.load 10 | end 11 | 12 | DB = Sequel.connect(ENV['DATABASE_URL']) 13 | # DB[:posts].truncate 14 | 15 | # CONTENT_PATH = "#{File.dirname(__FILE__)}/../content" 16 | # 17 | # def parse(file) 18 | # data = File.read(file) 19 | # post = JSON.parse(data) 20 | # url = file.sub(CONTENT_PATH,'').sub(/\.json$/,'') 21 | # 22 | # DB[:posts].insert(url: url, data: data) 23 | # 24 | # print "." 25 | # end 26 | # 27 | # desc "Rebuild database cache from all content JSON files." 28 | # task :rebuild do 29 | # Dir.glob("#{CONTENT_PATH}/**/*.json").each do |file| 30 | # parse(file) 31 | # end 32 | # end 33 | # 34 | # desc "Rebuild database cache from this month's content JSON files." 35 | # task :recent do 36 | # year_month = Time.now.strftime('%Y/%m') 37 | # files = Dir.glob("#{CONTENT_PATH}/#{year_month}/**/*.json") 38 | # # need to rebuild all cites and cards because they're not organised by month 39 | # files += Dir.glob("#{CONTENT_PATH}/cite/**/*.json") 40 | # files += Dir.glob("#{CONTENT_PATH}/card/**/*.json") 41 | # files.each do |file| 42 | # parse(file) 43 | # end 44 | # end 45 | # 46 | # desc "Fetch a context and store." 47 | # task :context_fetch, :url do |t, args| 48 | # url = args[:url] 49 | # Transformative::Context.fetch(url) 50 | # end 51 | 52 | # via https://stackoverflow.com/questions/22800017/sequel-generate-migration 53 | namespace :db do 54 | require "sequel" 55 | Sequel.extension :migration 56 | # DB = Sequel.connect(ENV['DATABASE_URL']) 57 | 58 | desc "Prints current schema version" 59 | task :version do 60 | version = if DB.tables.include?(:schema_info) 61 | DB[:schema_info].first[:version] 62 | end || 0 63 | 64 | puts "Schema Version: #{version}" 65 | end 66 | 67 | desc "Run migrations" 68 | task :migrate, [:version] do |t, args| 69 | if args[:version] 70 | puts "Migrating to version #{args[:version]}" 71 | Sequel::Migrator.run(DB, "db/migrate", target: args[:version].to_i) 72 | else 73 | puts "Migrating to latest" 74 | Sequel::Migrator.run(DB, "db/migrate") 75 | end 76 | Rake::Task['db:version'].execute 77 | end 78 | 79 | desc "Perform rollback to specified target or full rollback as default" 80 | task :rollback, :target do |t, args| 81 | args.with_defaults(:target => 0) 82 | 83 | Sequel::Migrator.run(DB, "db/migrate", :target => args[:target].to_i) 84 | Rake::Task['db:version'].execute 85 | end 86 | 87 | desc "Perform migration reset (full rollback and migration)" 88 | task :reset do 89 | Sequel::Migrator.run(DB, "db/migrate", :target => 0) 90 | Sequel::Migrator.run(DB, "db/migrate") 91 | Rake::Task['db:version'].execute 92 | end 93 | end 94 | -------------------------------------------------------------------------------- /app.rb: -------------------------------------------------------------------------------- 1 | require 'sinatra' 2 | 3 | class SiteWriter < Sinatra::Application 4 | # enable :sessions 5 | 6 | configure :production do 7 | set :haml, { :ugly=>true } 8 | set :clean_trace, true 9 | set :show_exceptions, false # supposed to be the default? 10 | end 11 | 12 | configure :development do 13 | # ... 14 | end 15 | 16 | helpers do 17 | include Rack::Utils 18 | alias_method :h, :escape_html 19 | end 20 | end 21 | 22 | class SitewriterError < StandardError 23 | attr_reader :type, :status 24 | def initialize(type, message, status=500) 25 | @type = type 26 | @status = status.to_i 27 | super(message) 28 | end 29 | end 30 | 31 | require_relative 'lib/init' 32 | require_relative 'models/init' 33 | # require_relative 'helpers/init' 34 | require_relative 'routes/init' 35 | -------------------------------------------------------------------------------- /config.ru: -------------------------------------------------------------------------------- 1 | $LOAD_PATH.unshift(File.dirname(__FILE__)) 2 | $LOAD_PATH.unshift(File.join(File.dirname(__FILE__), 'lib')) 3 | 4 | env = ENV['RACK_ENV'].to_sym || :development 5 | 6 | require "bundler/setup" 7 | Bundler.require(:default, env) 8 | 9 | Dotenv.load unless env == :production 10 | 11 | # optionally use sentry in production 12 | if env == :production && ENV.key?('SENTRY_DSN') 13 | Raven.configure do |config| 14 | config.dsn = ENV['SENTRY_DSN'] 15 | config.processors -= [Raven::Processor::PostData] 16 | end 17 | use Raven::Rack 18 | end 19 | 20 | # automatically parse json in the body 21 | use Rack::PostBodyContentTypeParser 22 | 23 | # session pool using redis via moneta 24 | # if env != :development 25 | # require 'rack/session/moneta' 26 | # use Rack::Session::Moneta, 27 | # key: 'sitewriter.net', 28 | # path: '/', 29 | # expire_after: 7*24*60*60, # one week 30 | # secret: ENV['SESSION_SECRET_KEY'], 31 | 32 | # store: Moneta.new(:Redis, { 33 | # url: ENV['REDISCLOUD_URL'], 34 | # expires: true, 35 | # threadsafe: true 36 | # }) 37 | # end 38 | 39 | root = ::File.dirname(__FILE__) 40 | require ::File.join( root, 'app' ) 41 | run SiteWriter.new 42 | -------------------------------------------------------------------------------- /db/migrate/001_create_sites.rb: -------------------------------------------------------------------------------- 1 | Sequel.migration do 2 | change do 3 | create_table(:sites) do 4 | primary_key :id 5 | String :domain, null: false 6 | String :url 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /db/migrate/002_create_stores.rb: -------------------------------------------------------------------------------- 1 | Sequel.migration do 2 | change do 3 | create_table(:stores) do 4 | primary_key :id 5 | foreign_key :site_id, :sites 6 | Int :type_id, null: false 7 | String :location 8 | String :user 9 | String :key 10 | end 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /db/migrate/003_create_flows.rb: -------------------------------------------------------------------------------- 1 | Sequel.migration do 2 | change do 3 | create_table(:flows) do 4 | primary_key :id 5 | foreign_key :site_id, :sites 6 | foreign_key :store_id, :stores 7 | foreign_key :media_store_id, :stores 8 | Int :post_type_id 9 | TrueClass :allow_media 10 | TrueClass :allow_meta 11 | String :name, size: 140 12 | String :path_template, size: 255 13 | String :url_template, size: 255 14 | String :content_template, text: true 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /db/migrate/004_add_flow_media_templates.rb: -------------------------------------------------------------------------------- 1 | Sequel.migration do 2 | change do 3 | alter_table(:flows) do 4 | add_column :media_path_template, String, size: 255 5 | add_column :media_url_template, String, size: 255 6 | end 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /db/migrate/005_update_flow_type_to_kind.rb: -------------------------------------------------------------------------------- 1 | Sequel.migration do 2 | up do 3 | add_column :flows, :post_kind, String, size: 255 4 | from(:flows).where(post_type_id: 1).update(post_kind: 'article') 5 | from(:flows).where(post_type_id: 2).update(post_kind: 'note') 6 | drop_column :flows, :post_type_id 7 | end 8 | 9 | down do 10 | add_column :flows, :post_type_id, Integer 11 | from(:flows).where(post_kind: 'article').update(post_type_id: 1) 12 | from(:flows).where(post_kind: 'note').update(post_type_id: 2) 13 | drop_column :flows, :post_kind 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /db/migrate/006_create_log.rb: -------------------------------------------------------------------------------- 1 | Sequel.migration do 2 | change do 3 | create_table(:log) do 4 | primary_key :id 5 | foreign_key :site_id, :sites 6 | foreign_key :flow_id, :flows 7 | 8 | column :started_at, 'timestamp without time zone', index: true 9 | column :finished_at, 'timestamp without time zone' 10 | String :ip 11 | String :user_agent 12 | column :properties, :jsonb 13 | String :request, text: true 14 | String :kind 15 | String :url, size: 255 16 | Integer :status_code 17 | column :error, :jsonb 18 | end 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /db/migrate/007_add_file_flow_to_sites.rb: -------------------------------------------------------------------------------- 1 | Sequel.migration do 2 | change do 3 | alter_table(:sites) do 4 | add_column :file_flow_id, Integer 5 | end 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /db/migrate/008_add_settings_to_sites.rb: -------------------------------------------------------------------------------- 1 | Sequel.migration do 2 | change do 3 | alter_table(:sites) do 4 | add_column :timezone, String, size: 255, default: 'Etc/UTC' 5 | add_column :private, TrueClass, default: true 6 | add_column :generator, String, size: 255 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /db/migrate/009_add_file_to_log.rb: -------------------------------------------------------------------------------- 1 | Sequel.migration do 2 | change do 3 | alter_table(:log) do 4 | add_column :file, String, size: 255 5 | end 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /db/migrate/010_add_photos_to_log.rb: -------------------------------------------------------------------------------- 1 | Sequel.migration do 2 | change do 3 | alter_table(:log) do 4 | add_column :photos, String, text:true 5 | end 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /lib/_transformative/authorship.rb: -------------------------------------------------------------------------------- 1 | module Transformative 2 | module Authorship 3 | module_function 4 | 5 | def fetch(url) 6 | return unless Utils.valid_url?(url) 7 | get_author(url) 8 | end 9 | 10 | def get_author(url) 11 | json = Microformats.parse(url).to_json 12 | items = JSON.parse(json)['items'] 13 | 14 | # find first h-entry 15 | entry = find_first_hentry(items) 16 | if entry.nil? 17 | raise AuthorshipError.new("No h-entry found at #{url}.") 18 | end 19 | 20 | # find author in the entry or on the page 21 | author = find_author(entry, url) 22 | return if author.nil? 23 | 24 | # find author properties 25 | if author.is_a?(Hash) && author['type'][0] == 'h-card' 26 | Card.new(author['properties']) 27 | elsif author.is_a?(String) 28 | url = if Utils.valid_url?(author) 29 | author 30 | else 31 | begin 32 | URI.join(url, author).to_s 33 | rescue URI::InvalidURIError 34 | end 35 | end 36 | get_author_hcard(url) if url 37 | end 38 | end 39 | 40 | def find_first_hentry(items) 41 | items.each do |item| 42 | if item['type'][0] == 'h-entry' 43 | return item 44 | end 45 | end 46 | end 47 | 48 | def find_author(entry, url) 49 | body = HTTParty.get(url).body 50 | if entry.is_a?(Hash) && entry['properties'].key?('author') 51 | entry['properties']['author'][0] 52 | elsif author_rel = Nokogiri::HTML(body).css("[rel=author]") 53 | author_rel.attribute('href').value 54 | end 55 | end 56 | 57 | def get_author_hcard(url) 58 | json = Microformats.parse(url).to_json 59 | properties = JSON.parse(json)['items'][0]['properties'] 60 | # force the url to be this absolute url 61 | properties['url'] = [url] 62 | Card.new(properties) 63 | end 64 | 65 | end 66 | end -------------------------------------------------------------------------------- /lib/_transformative/context.rb: -------------------------------------------------------------------------------- 1 | module Transformative 2 | module Context 3 | module_function 4 | 5 | def fetch_contexts(post) 6 | if post.properties.key?('in-reply-to') 7 | post.properties['in-reply-to'].each { |url| fetch(url) } 8 | elsif post.properties.key?('repost-of') 9 | post.properties['repost-of'].each { |url| fetch(url) } 10 | elsif post.properties.key?('like-of') 11 | post.properties['like-of'].each { |url| fetch(url) } 12 | end 13 | end 14 | 15 | def fetch(url) 16 | parsed = if url.match(/^https?:\/\/twitter\.com/) || 17 | url.match(/^https?:\/\/mobile\.twitter\.com/) 18 | parse_twitter(url) 19 | elsif url.match(/instagram\.com/) 20 | parse_instagram(url) 21 | else 22 | parse_mf2(url) 23 | end 24 | return if parsed.nil? 25 | # create author h-card 26 | unless parsed[1].nil? 27 | Store.save(parsed[1]) 28 | end 29 | # create h-cite 30 | Store.save(parsed[0]) 31 | end 32 | 33 | def parse_mf2(url) 34 | json = Microformats.parse(url).to_json 35 | items = JSON.parse(json)['items'] 36 | item = find_first_hentry_or_hevent(items) 37 | return if item.nil? 38 | # get author first 39 | author = Authorship.fetch(url) 40 | # construct entry 41 | properties = item['properties'] 42 | published = properties.key?('published') ? 43 | Time.parse(properties['published'][0]) : Time.now 44 | post_url = properties.key?('url') ? properties['url'][0] : url 45 | hash = { 46 | 'url' => [URI.join(url, post_url).to_s], 47 | 'name' => [properties['name'][0].strip], 48 | 'published' => [published.utc.iso8601], 49 | 'content' => [properties['content'][0]['value']], 50 | 'author' => [author.properties['url'][0]] 51 | } 52 | if properties.key?('photo') 53 | hash['photo'] = properties['photo'] 54 | end 55 | if properties.key?('start') 56 | hash['start'] = properties['start'] 57 | end 58 | if properties.key?('end') 59 | hash['end'] = properties['end'] 60 | end 61 | if properties.key?('location') 62 | hash['location'] = properties['location'] 63 | end 64 | cite = Cite.new(hash) 65 | [cite, author] 66 | end 67 | 68 | def parse_twitter(url) 69 | tweet_id = url.split('/').last 70 | tweet = twitter_client.status(tweet_id) 71 | cite_properties = { 72 | 'url' => [url], 73 | 'content' => [tweet.text.dup], 74 | 'author' => ["https://twitter.com/#{tweet.user.screen_name}"], 75 | 'published' => [Time.parse(tweet.created_at.to_s).utc] 76 | } 77 | # does the tweet have photo(s)? 78 | if tweet.media.any? 79 | cite_properties['photo'] = tweet.media.map { |m| m.media_url.to_s } 80 | end 81 | # replace t.co links with expanded versions 82 | tweet.urls.each do |u| 83 | cite_properties['content'][0].sub!(u.url.to_s, u.expanded_url.to_s) 84 | end 85 | cite = Cite.new(cite_properties) 86 | author_properties = { 87 | 'url' => ["https://twitter.com/#{tweet.user.screen_name}"], 88 | 'name' => [tweet.user.name], 89 | 'photo' => ["#{tweet.user.profile_image_url.scheme}://" + 90 | tweet.user.profile_image_url.host + 91 | tweet.user.profile_image_url.path] 92 | } 93 | # TODO: copy the photo somewhere else and reference it 94 | author = Card.new(author_properties) 95 | [cite, author] 96 | end 97 | 98 | def parse_instagram(url) 99 | url = tidy_instagram_url(url) 100 | json = HTTParty.get( 101 | "http://api.instagram.com/oembed?url=#{CGI::escape(url)}").body 102 | body = JSON.parse(json) 103 | cite_properties = { 104 | 'url' => [url], 105 | 'author' => [body['author_url']], 106 | 'photo' => [body['thumbnail_url']], 107 | 'content' => [body['title']] 108 | } 109 | cite = Cite.new(cite_properties) 110 | author_properties = { 111 | 'url' => [body['author_url']], 112 | 'name' => [body['author_name']] 113 | } 114 | author = Card.new(author_properties) 115 | [cite, author] 116 | end 117 | 118 | # strip cruft from URL, e.g. #liked-by-xxxx or modal=true from instagram 119 | def tidy_instagram_url(url) 120 | uri = URI.parse(url) 121 | uri.fragment = nil 122 | uri.query = nil 123 | uri.to_s 124 | end 125 | 126 | def find_first_hentry_or_hevent(items) 127 | items.each do |item| 128 | if item['type'][0] == 'h-entry' || item['type'][0] == 'h-event' 129 | return item 130 | end 131 | end 132 | end 133 | 134 | def twitter_client 135 | Utils.twitter_client 136 | end 137 | 138 | end 139 | end -------------------------------------------------------------------------------- /lib/_transformative/media_s3.rb: -------------------------------------------------------------------------------- 1 | module Media 2 | module_function 3 | 4 | def save(file) 5 | filename = "#{Time.now.strftime('%Y/%m/%d')}-#{SecureRandom.hex.to_s}" 6 | ext = file[:filename].match(/\./) ? '.' + 7 | file[:filename].split('.').last : ".jpg" 8 | filepath = "file/#{filename}#{ext}" 9 | content = file[:tempfile].read 10 | 11 | if ENV['RACK_ENV'] == 'production' 12 | # upload to github (canonical store) 13 | Store.upload(filepath, content) 14 | # upload to s3 (serves file) 15 | s3_upload(filepath, content, ext, file[:type]) 16 | else 17 | rootpath = "#{File.dirname(__FILE__)}/../../../content/" 18 | FileSystem.new.upload(rootpath + filepath, content) 19 | end 20 | 21 | URI.join(ENV['MEDIA_URL'], filepath).to_s 22 | end 23 | 24 | def upload_files(files) 25 | files.map do |file| 26 | upload_file(file) 27 | end 28 | end 29 | 30 | def upload_file(file) 31 | if Utils.valid_url?(file) 32 | # TODO extract file from url and store? 33 | file 34 | else 35 | save(file) 36 | end 37 | end 38 | 39 | def s3_upload(filepath, content, ext, content_type) 40 | object = bucket.objects.build(filepath) 41 | object.content = content 42 | object.content_type = content_type 43 | object.acl = :public_read 44 | object.save 45 | end 46 | 47 | def s3 48 | @s3 ||= S3::Service.new( 49 | access_key_id: ENV['AWS_ACCESS_KEY_ID'], 50 | secret_access_key: ENV['AWS_SECRET_ACCESS_KEY'] 51 | ) 52 | end 53 | 54 | def bucket 55 | @bucket ||= s3.bucket(ENV['AWS_BUCKET']) 56 | end 57 | 58 | end 59 | -------------------------------------------------------------------------------- /lib/_transformative/notification.rb: -------------------------------------------------------------------------------- 1 | module Transformative 2 | module Notification 3 | module_function 4 | 5 | def send(title, message, url) 6 | if ENV.key?('PUSHOVER_USER') && ENV.key?('PUSHOVER_TOKEN') 7 | pushover(title, message, url) 8 | end 9 | end 10 | 11 | def pushover(title, message, url) 12 | response = HTTParty.post('https://api.pushover.net/1/messages.json', { 13 | body: { 14 | token: ENV['PUSHOVER_TOKEN'], 15 | user: ENV['PUSHOVER_USER'], 16 | title: title, 17 | message: message, 18 | url: url 19 | } 20 | }) 21 | end 22 | 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /lib/_transformative/syndication.rb: -------------------------------------------------------------------------------- 1 | module Transformative 2 | module Syndication 3 | module_function 4 | 5 | def send(post, service) 6 | if service.start_with?('https://twitter.com/') 7 | send_twitter(post) 8 | elsif service.start_with?('https://pinboard.in/') 9 | send_pinboard(post) 10 | end 11 | end 12 | 13 | def send_pinboard(post) 14 | # no person-tags (or other urls) 15 | tags = if post.properties.key?('category') 16 | post.properties['category'].map { |tag| 17 | tag unless Utils.valid_url?(tag) 18 | }.compact.join(',') 19 | else 20 | "" 21 | end 22 | opts = { 23 | 'auth_token' => ENV['PINBOARD_AUTH_TOKEN'], 24 | 'url' => post.properties['bookmark-of'][0], 25 | 'description' => post.properties['name'][0], 26 | 'extended' => post.content, 27 | 'tags' => tags, 28 | 'dt' => post.properties.key?('published') ? 29 | post.properties['published'][0] : Time.now.utc.iso8601 30 | } 31 | pinboard_url = "https://api.pinboard.in/v1/posts/add" 32 | HTTParty.get(pinboard_url, query: opts) 33 | return 34 | end 35 | 36 | def send_twitter(post) 37 | # we can only send entries to twitter so ignore anything else 38 | # TODO syndicate other objects 39 | return unless post.h_type == 'h-entry' 40 | 41 | case post.entry_type 42 | when 'repost' 43 | Twitter.retweet(post.properties['repost-of']) 44 | when 'like' 45 | Twitter.favorite(post.properties['like-of']) 46 | else 47 | Twitter.update(post) 48 | end 49 | end 50 | 51 | end 52 | 53 | class SyndicationError < SitewriterError 54 | def initialize(message) 55 | super("syndication", message) 56 | end 57 | end 58 | 59 | end 60 | -------------------------------------------------------------------------------- /lib/_transformative/twitter.rb: -------------------------------------------------------------------------------- 1 | module Transformative 2 | module Twitter 3 | module_function 4 | 5 | TWITTER_STATUS_REGEX = 6 | /^https?:\/\/twitter\.com\/(?:#!\/)?(\w+)\/status(es)?\/(\d+)$/ 7 | 8 | def update(post) 9 | status = get_status(post) 10 | return if status.empty? 11 | 12 | options = {} 13 | if reply_tweet_id = get_reply(post) 14 | options[:in_reply_to_status_id] = reply_tweet_id 15 | end 16 | if media = get_media(post) 17 | media_ids = media.map { |file| client.upload(file) } 18 | options[:media_ids] = media_ids.join(',') 19 | end 20 | 21 | tweet = client.update(status, options) 22 | "https://twitter.com/#{tweet.user.screen_name}/status/#{tweet.id}" 23 | end 24 | 25 | def get_status(post) 26 | # prioritise summary, name (+ url) then content 27 | # TODO: ellipsize 28 | if post.properties.key?('summary') 29 | post.properties['summary'][0] 30 | elsif post.properties.key?('name') 31 | "#{post.properties['name'][0]}: #{post.absolute_url}" 32 | elsif post.properties.key?('content') 33 | if post.properties['content'][0].is_a?(Hash) && 34 | post.properties['content'][0].key?('html') 35 | Sanitize.fragment(post.properties['content'][0]['html']).strip 36 | else 37 | post.properties['content'][0] 38 | end 39 | else 40 | "" 41 | end 42 | end 43 | 44 | def get_reply(post) 45 | # use first twitter url from in-reply-to list 46 | if post.properties.key?('in-reply-to') && 47 | post.properties['in-reply-to'].is_a?(Array) 48 | post.properties['in-reply-to'].each do |url| 49 | if tweet_id = tweet_id_from_url(url) 50 | return tweet_id 51 | end 52 | end 53 | end 54 | end 55 | 56 | def get_media(post) 57 | if post.properties.key?('photo') && 58 | post.properties['photo'].is_a?(Array) 59 | post.properties['photo'].map do |photo| 60 | if photo.is_a?(Hash) 61 | open(photo['value']) 62 | else 63 | open(photo) 64 | end 65 | end 66 | end 67 | end 68 | 69 | def retweet(urls) 70 | return unless tweet_id = find_first_tweet_id_from_urls(urls) 71 | tweet = client.retweet(tweet_id) 72 | "https://twitter.com/#{tweet[0].user.screen_name}/status/#{tweet[0].id}" 73 | end 74 | 75 | def favorite(urls) 76 | return unless tweet_id = find_first_tweet_id_from_urls(urls) 77 | tweet = client.favorite(tweet_id) 78 | "https://twitter.com/#{tweet[0].user.screen_name}/status/#{tweet[0].id}" 79 | end 80 | 81 | def tweet_id_from_url(url) 82 | return unless tweet_parts = url.match(TWITTER_STATUS_REGEX) 83 | tweet_parts[3] 84 | end 85 | 86 | def find_first_tweet_id_from_urls(urls) 87 | urls.each do |url| 88 | if tweet_id = tweet_id_from_url(url) 89 | return tweet_id 90 | end 91 | end 92 | end 93 | 94 | def client 95 | Utils.twitter_client 96 | end 97 | 98 | end 99 | end -------------------------------------------------------------------------------- /lib/_transformative/utils.rb: -------------------------------------------------------------------------------- 1 | module Transformative 2 | module Utils 3 | module_function 4 | 5 | def valid_url?(url) 6 | begin 7 | uri = URI.parse(url) 8 | uri.is_a?(URI::HTTP) || uri.is_a?(URI::HTTPS) 9 | rescue URI::InvalidURIError 10 | end 11 | end 12 | 13 | def slugify_url(url) 14 | url.to_s.downcase.gsub(/[^a-z0-9\-]/,' ').strip.gsub(/\s+/,'/') 15 | end 16 | 17 | def relative_url(url) 18 | url.sub!(ENV['SITE_URL'], '') 19 | url.start_with?('/') ? url : "/#{url}" 20 | end 21 | 22 | def ping_pubsubhubbub 23 | HTTParty.post(ENV['PUBSUBHUBBUB_HUB'], { 24 | body: { 25 | "hub.mode": "publish", 26 | "hub.url": ENV['SITE_URL'] 27 | } 28 | }) 29 | end 30 | 31 | def twitter_client 32 | @twitter_client ||= ::Twitter::REST::Client.new do |config| 33 | config.consumer_key = ENV['TWITTER_CONSUMER_KEY'] 34 | config.consumer_secret = ENV['TWITTER_CONSUMER_SECRET'] 35 | config.access_token = ENV['TWITTER_ACCESS_TOKEN'] 36 | config.access_token_secret = ENV['TWITTER_ACCESS_TOKEN_SECRET'] 37 | end 38 | end 39 | 40 | end 41 | end -------------------------------------------------------------------------------- /lib/_transformative/view_helper.rb: -------------------------------------------------------------------------------- 1 | require 'openssl' 2 | 3 | module Transformative 4 | module ViewHelper 5 | 6 | def h(text) 7 | Rack::Utils.escape_html(text) 8 | end 9 | 10 | def filter_markdown(content) 11 | return "" if content.nil? 12 | Redcarpet::Render::SmartyPants.render( 13 | Redcarpet::Markdown.new( 14 | Redcarpet::Render::HTML, autolink: true 15 | ).render(content) 16 | ) 17 | end 18 | 19 | def post_type_icon(post) 20 | icon = case post.properties['entry-type'][0] 21 | when 'article' 22 | "file-text" 23 | when 'bookmark' 24 | "bookmark" 25 | when 'photo' 26 | "camera" 27 | when 'reply' 28 | "reply" 29 | when 'repost' 30 | "retweet" 31 | when 'like' 32 | "heart" 33 | when 'rsvp' 34 | if post.properties.key?('rsvp') 35 | case post.properties['rsvp'][0] 36 | when 'yes', true 37 | 'calendar-check-o' 38 | when 'no', false 39 | 'calendar-times-o' 40 | else 41 | 'calendar-o' 42 | end 43 | else 44 | "calendar" 45 | end 46 | when 'checkin' 47 | "compass" 48 | else 49 | "comment" 50 | end 51 | "" 52 | end 53 | 54 | def context_prefix(entry) 55 | if entry.properties.key?('in-reply-to') 56 | "In reply to" 57 | elsif entry.properties.key?('repost-of') 58 | "Reposted" 59 | elsif entry.properties.key?('like-of') 60 | "Liked" 61 | elsif entry.properties.key?('rsvp') 62 | "RSVP to" 63 | end 64 | end 65 | 66 | def syndication_text(syndication) 67 | host = URI.parse(syndication).host 68 | case host 69 | when 'twitter.com', 'mobile.twitter.com' 70 | " Twitter" 71 | when 'instagram.com', 'www.instagram.com' 72 | " Instagram" 73 | when 'facebook.com', 'www.facebook.com' 74 | " Facebook" 75 | when 'swarmapp.com', 'www.swarmapp.com' 76 | " Swarm" 77 | when 'news.indieweb.org' 78 | "IndieNews" 79 | when 'medium.com' 80 | " Medium" 81 | else 82 | host 83 | end 84 | end 85 | 86 | def webmention_type(post) 87 | if post.properties.key?('in-reply-to') 88 | 'reply' 89 | elsif post.properties.key?('repost-of') 90 | 'repost' 91 | elsif post.properties.key?('like-of') 92 | 'like' 93 | else 94 | 'mention' 95 | end 96 | end 97 | 98 | def webmention_type_p_class(type) 99 | case type 100 | when "reply","comment" 101 | return "p-comment" # via http://microformats.org/wiki/comment-brainstorming#microformats2_p-comment_h-entry 102 | when "like" 103 | return "p-like" 104 | when "repost" 105 | return "p-repost" 106 | when "mention" 107 | "p-mention" 108 | end 109 | end 110 | def post_type_u_class(type) 111 | case type 112 | when "reply", "rsvp" 113 | return "u-in-reply-to" 114 | when "repost" 115 | return "u-repost-of u-repost" 116 | when "like" 117 | return "u-like-of u-like" 118 | end 119 | end 120 | def context_class(post) 121 | if post.properties.key?('in-reply-to') 122 | "u-in-reply-to" 123 | elsif post.properties.key?('repost-of') 124 | "u-repost-of" 125 | elsif post.properties.key?('like-of') 126 | "u-like-of" 127 | end 128 | end 129 | def post_type_p_class(post) 130 | if post.properties.key?('in-reply-to') 131 | "p-in-reply-to" 132 | elsif post.properties.key?('repost-of') 133 | "p-repost-of" 134 | elsif post.properties.key?('like-of') 135 | "p-like-of" 136 | elsif post.properties.key?('rsvp') 137 | "p-rsvp" 138 | end 139 | end 140 | def context_tag(post) 141 | if post.h_type == 'h-entry' 142 | case post.entry_type 143 | when "reply", "rsvp" 144 | property = "in-reply-to" 145 | klass = "u-in-reply-to" 146 | when "repost" 147 | property = "repost-of" 148 | klass = "u-repost-of" 149 | when "like" 150 | property = "like-of" 151 | klass = "u-like-of" 152 | when "bookmark" 153 | property = "bookmark-of" 154 | klass = "u-bookmark-of" 155 | else 156 | return 157 | end 158 | tags = post.properties[property].map do |url| 159 | "" 160 | end 161 | tags.join('') 162 | end 163 | end 164 | 165 | def webmention_type_icon(type) 166 | case type 167 | when 'reply' 168 | return "" 169 | when 'repost' 170 | return "" 171 | when 'like' 172 | return "" 173 | else 174 | return "" 175 | end 176 | end 177 | 178 | def webmention_type_text(type) 179 | case type 180 | when 'reply' 181 | return "replied to this" 182 | when 'repost' 183 | return "reposted this" 184 | when 'like' 185 | return "liked this" 186 | else 187 | return "mentioned this" 188 | end 189 | end 190 | 191 | def host_link(url) 192 | host = URI.parse(url).host.downcase 193 | case host 194 | when "twitter.com","mobile.twitter.com" 195 | host_text = " Twitter" 196 | when "instagram.com", "www.instagram.com" 197 | host_text = " Instagram" 198 | else 199 | host_text = host 200 | end 201 | "#{host_text}" 202 | end 203 | 204 | def post_summary(content, num=200) 205 | summary = Sanitize.clean(content).to_s.strip[0...num] 206 | if summary.size == num 207 | summary = summary.strip + "…" 208 | end 209 | summary 210 | end 211 | 212 | def post_split(content) 213 | paragraph_ends = content.split(/\n\n/) 214 | return content unless paragraph_ends.size > 3 215 | paragraph_ends.first + " " + 216 | "Read full post…" 217 | end 218 | 219 | def filter_all(content) 220 | content = link_twitter(content) 221 | content = link_hashtags(content) 222 | content 223 | end 224 | 225 | def link_urls(content) 226 | content.gsub /((https?:\/\/|www\.)([-\w\.]+)+(:\d+)?(\/([\w\/_\.\+\-]*(\?\S+)?)?)?)/, %Q{\\1} 227 | end 228 | def link_twitter(content) 229 | content.gsub /\B@(\w*[a-zA-Z0-9_-]+)\w*/i, %Q{@\\1\\2} 230 | end 231 | def link_hashtags(content) 232 | return content 233 | # hashtags => link internally 234 | content.gsub /\B#(\w*[a-zA-Z0-9]+)\w*/i, %Q{ #\\1 } 235 | end 236 | def link_hashtags_twitter(content) 237 | # hashtags => link to twitter search 238 | content.gsub /\B#(\w*[a-zA-Z0-9]+)\w*/i, %Q{ #\\1 } 239 | end 240 | 241 | def force_https_author_profile(photo_url, base_url) 242 | url = URI.join(base_url, photo_url).to_s 243 | if url.start_with?('http://pbs.twimg.com') 244 | url.gsub 'http://pbs.twimg.com', 'https://pbs.twimg.com' 245 | elsif !url.start_with?('https') 246 | camo_image(url) 247 | else 248 | url 249 | end 250 | end 251 | 252 | # from https://github.com/atmos/camo/blob/master/test/proxy_test.rb 253 | def hexenc(image_url) 254 | image_url.to_enum(:each_byte).map { |byte| "%02x" % byte }.join 255 | end 256 | def camo_image(image_url) 257 | hexdigest = OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new('sha1'), 258 | ENV['CAMO_KEY'], image_url) 259 | encoded_image_url = hexenc(image_url) 260 | "#{ENV['CAMO_URL']}#{hexdigest}/#{encoded_image_url}" 261 | end 262 | 263 | def is_tweet?(post) 264 | url = post.properties['url'][0] 265 | url.start_with?('https://twitter.com') || 266 | url.start_with?('https://mobile.twitter.com') 267 | end 268 | 269 | def host_link(url) 270 | host = URI.parse(url).host.downcase 271 | case host 272 | when "twitter.com","mobile.twitter.com" 273 | host_text = " Twitter" 274 | when "instagram.com", "www.instagram.com" 275 | host_text = " Instagram" 276 | else 277 | host_text = host 278 | end 279 | "#{host_text}" 280 | end 281 | 282 | def valid_url?(url) 283 | Utils.valid_url?(url) 284 | end 285 | 286 | def nav(path, text) 287 | if path == request.path_info 288 | "
#{TYPES_CATALOG[type.to_s]['description']} Read more.
" 264 | end 265 | 266 | def self.variables_for_type(type) 267 | VARIABLES_CATALOG.merge(class_for_type(type)::VARIABLES_CATALOG).select{|k,v| !v.nil?} 268 | end 269 | 270 | def self.type_supports_attachments?(type) 271 | TYPES_CATALOG[type.to_s]['attachments'] 272 | end 273 | 274 | end 275 | -------------------------------------------------------------------------------- /lib/post_types.yml: -------------------------------------------------------------------------------- 1 | note: 2 | description: | 3 | A simple text post, usually short, without a title. 4 | link: https://indieweb.org/note 5 | attachments: true 6 | class: Note 7 | article: 8 | description: | 9 | A titled post, usually long. 10 | link: https://indieweb.org/article 11 | attachments: true 12 | class: Article 13 | photo: 14 | description: | 15 | A photo (or photos) with some description. 16 | link: https://indieweb.org/photo 17 | attachments: true 18 | class: Photo 19 | _video: 20 | description: | 21 | A video (or videos) with some description (and optionally thumbnails). 22 | link: https://indieweb.org/video 23 | attachments: true 24 | class: Video 25 | _audio: 26 | description: | 27 | A audio file (or files) with some description. 28 | link: https://indieweb.org/audio 29 | attachments: true 30 | class: Audio 31 | bookmark: 32 | description: | 33 | A bookmark of another URL, often with some comments or metadata. 34 | link: https://indieweb.org/bookmark 35 | attachments: false 36 | class: Bookmark 37 | checkin: 38 | description: | 39 | A location and time, often with some description. 40 | link: https://indieweb.org/checkin 41 | attachments: true 42 | class: Checkin 43 | like: 44 | description: | 45 | A "like" or "favorite" or "star" affirming a post at another URL. 46 | link: https://indieweb.org/like 47 | attachments: false 48 | class: Like 49 | reply: 50 | description: | 51 | A comment on another post, from anywhere. 52 | link: https://indieweb.org/reply 53 | attachments: false 54 | class: Reply 55 | _repost: 56 | description: | 57 | Republication of another post, from anywhere. 58 | link: https://indieweb.org/repost 59 | attachments: true 60 | class: Repost 61 | _event: 62 | description: | 63 | An event with a specific time and location. 64 | link: https://indieweb.org/event 65 | attachments: true 66 | class: Event 67 | -------------------------------------------------------------------------------- /lib/posts/_entry.rb: -------------------------------------------------------------------------------- 1 | class Entry < Post 2 | 3 | def initialize(properties, url=nil) 4 | super(properties, url) 5 | end 6 | 7 | def h_type 8 | 'h-entry' 9 | end 10 | 11 | def generate_url 12 | generate_url_published 13 | end 14 | 15 | def entry_type 16 | if @properties.key?('rsvp') && 17 | %w( yes no maybe interested ).include?(@properties['rsvp'][0]) 18 | 'rsvp' 19 | elsif @properties.key?('in-reply-to') && 20 | Utils.valid_url?(@properties['in-reply-to'][0]) 21 | 'reply' 22 | elsif @properties.key?('repost-of') && 23 | Utils.valid_url?(@properties['repost-of'][0]) 24 | 'repost' 25 | elsif @properties.key?('like-of') && 26 | Utils.valid_url?(@properties['like-of'][0]) 27 | 'like' 28 | elsif @properties.key?('video') && 29 | Utils.valid_url?(@properties['video'][0]) 30 | 'video' 31 | elsif @properties.key?('photo') && 32 | Utils.valid_url?(@properties['photo'][0]) 33 | 'photo' 34 | elsif @properties.key?('bookmark-of') && 35 | Utils.valid_url?(@properties['bookmark-of'][0]) 36 | 'bookmark' 37 | elsif @properties.key?('name') && !@properties['name'].empty? && 38 | !content_start_with_name? 39 | 'article' 40 | elsif @properties.key?('checkin') 41 | 'checkin' 42 | else 43 | 'note' 44 | end 45 | end 46 | 47 | def content_start_with_name? 48 | return unless @properties.key?('content') && @properties.key?('name') 49 | content = @properties['content'][0].is_a?(Hash) && 50 | @properties['content'][0].key?('html') ? 51 | @properties['content'][0]['html'] : @properties['content'][0] 52 | content_tidy = content.gsub(/\s+/, " ").strip 53 | name_tidy = @properties['name'][0].gsub(/\s+/, " ").strip 54 | content_tidy.start_with?(name_tidy) 55 | end 56 | 57 | def cite_belongs_to_post?(cite) 58 | property = case @properties['entry-type'][0] 59 | when 'reply', 'rsvp' 60 | 'in-reply-to' 61 | when 'repost' 62 | 'repost-of' 63 | when 'like' 64 | 'like-of' 65 | else 66 | return 67 | end 68 | @properties[property].include?(cite.properties['url'][0]) 69 | end 70 | 71 | def self.new_from_form(params) 72 | # wrap each non-array value in an array 73 | deep_props = Hash[ params.map { |k, v| [k, Array(v)] } ] 74 | self.new(deep_props) 75 | end 76 | 77 | end 78 | -------------------------------------------------------------------------------- /lib/posts/article.rb: -------------------------------------------------------------------------------- 1 | class Article < Post 2 | 3 | VARIABLES_CATALOG = { 4 | title: 'post title' 5 | } 6 | 7 | attr_accessor :title 8 | 9 | def initialize(properties, url=nil) 10 | super(properties, url) 11 | if properties.key?('name') 12 | @title = properties['name'][0] 13 | else 14 | @title = '' 15 | end 16 | end 17 | 18 | def kind 19 | 'article' 20 | end 21 | 22 | def render_variables 23 | return super().merge({ 24 | title: @title 25 | }) 26 | end 27 | 28 | end 29 | -------------------------------------------------------------------------------- /lib/posts/audio.rb: -------------------------------------------------------------------------------- 1 | class Audio < Post 2 | 3 | VARIABLES_CATALOG = { 4 | } 5 | 6 | def initialize(properties, url=nil) 7 | super(properties, url) 8 | end 9 | 10 | def kind 11 | 'audio' 12 | end 13 | 14 | end 15 | -------------------------------------------------------------------------------- /lib/posts/bookmark.rb: -------------------------------------------------------------------------------- 1 | class Bookmark < Post 2 | 3 | VARIABLES_CATALOG = { 4 | name: 'bookmark name', 5 | url: 'bookmarked url' 6 | } 7 | 8 | def initialize(properties, url=nil) 9 | super(properties, url) 10 | if properties.key?('name') 11 | @name = properties['name'][0] 12 | else 13 | @name = '' 14 | end 15 | if properties.key?('bookmark-of') 16 | @url = properties['bookmark-of'][0] 17 | else 18 | @url = '' 19 | end 20 | end 21 | 22 | def kind 23 | 'bookmark' 24 | end 25 | 26 | def render_variables 27 | return super().merge({ 28 | name: @name, 29 | url: @url 30 | }) 31 | end 32 | 33 | end 34 | -------------------------------------------------------------------------------- /lib/posts/checkin.rb: -------------------------------------------------------------------------------- 1 | class Checkin < Post 2 | 3 | VARIABLES_CATALOG = { 4 | name: 'name of venue', 5 | url: 'venue URL', 6 | 7 | latitude: 'venue latitude', 8 | longitude: 'venue longitude', 9 | 10 | address: 'venue street address', 11 | locality: 'venue city', 12 | region: 'venue region', 13 | country: 'venue country', 14 | postal: 'venue postal code', 15 | 16 | tel: 'venue telephone number' 17 | } 18 | 19 | def initialize(properties, url=nil) 20 | super(properties, url) 21 | @name = '' 22 | @url = '' 23 | @latitude = '' 24 | @longitude = '' 25 | @address = '' 26 | @locality = '' 27 | @region = '' 28 | @country = '' 29 | @postal = '' 30 | @telephone = '' 31 | if properties.key?('checkin') 32 | if properties['checkin'][0].respond_to?(:key) && properties['checkin'][0].key?('type') && properties['checkin'][0]['type'][0] == 'h-card' 33 | checkin_props = properties['checkin'][0]['properties'] 34 | if checkin_props.key?('name') 35 | @name = checkin_props['name'][0] 36 | else 37 | @name = '' 38 | end 39 | if checkin_props.key?('url') 40 | @url = checkin_props['url'][0] 41 | else 42 | @url = '' 43 | end 44 | if checkin_props.key?('latitude') 45 | @latitude = checkin_props['latitude'][0] 46 | else 47 | @latitude = '' 48 | end 49 | if checkin_props.key?('longitude') 50 | @longitude = checkin_props['longitude'][0] 51 | else 52 | @longitude = '' 53 | end 54 | if checkin_props.key?('street-address') 55 | @address = checkin_props['street-address'][0] 56 | else 57 | @address = '' 58 | end 59 | if checkin_props.key?('locality') 60 | @locality = checkin_props['locality'][0] 61 | else 62 | @locality = '' 63 | end 64 | if checkin_props.key?('region') 65 | @region = checkin_props['region'][0] 66 | else 67 | @region = '' 68 | end 69 | if checkin_props.key?('country') 70 | @country = checkin_props['country'][0] 71 | else 72 | @country = '' 73 | end 74 | if checkin_props.key?('postal-code') 75 | @postal = checkin_props['postal-code'][0] 76 | else 77 | @postal = '' 78 | end 79 | if checkin_props.key?('tel') 80 | @telephone = checkin_props['tel'][0] 81 | else 82 | @telephone = '' 83 | end 84 | # default slug 85 | @slug = @name || @locality || @region || @country 86 | else 87 | raise "Checkin without an h-card" 88 | end 89 | # this should be unreachable 90 | end 91 | end 92 | 93 | def kind 94 | 'checkin' 95 | end 96 | 97 | def render_variables 98 | return super().merge({ 99 | name: @name, 100 | url: @url, 101 | 102 | latitude: @latitude, 103 | longitude: @longitude, 104 | 105 | address: @address, 106 | locality: @locality, 107 | region: @region, 108 | country: @country, 109 | postal: @postal, 110 | 111 | telephone: @telephone 112 | }) 113 | end 114 | 115 | end 116 | -------------------------------------------------------------------------------- /lib/posts/event.rb: -------------------------------------------------------------------------------- 1 | class Event < Post 2 | 3 | VARIABLES_CATALOG = { 4 | } 5 | 6 | def initialize(properties, url=nil) 7 | super(properties, url) 8 | end 9 | 10 | def kind 11 | 'event' 12 | end 13 | 14 | end 15 | -------------------------------------------------------------------------------- /lib/posts/like.rb: -------------------------------------------------------------------------------- 1 | class Like < Post 2 | 3 | VARIABLES_CATALOG = { 4 | like_of: 'liked URL', 5 | 6 | content: nil, 7 | has_photos: nil, 8 | photos: nil 9 | } 10 | 11 | def initialize(properties, url=nil) 12 | super(properties, url) 13 | if properties.key?('like-of') 14 | @like_of = properties['like-of'][0] 15 | else 16 | @like_of = '' 17 | end 18 | end 19 | 20 | def kind 21 | 'like' 22 | end 23 | 24 | def render_variables 25 | return super().merge({ 26 | like_of: @like_of 27 | }) 28 | end 29 | 30 | end 31 | -------------------------------------------------------------------------------- /lib/posts/note.rb: -------------------------------------------------------------------------------- 1 | class Note < Post 2 | 3 | VARIABLES_CATALOG = { 4 | } 5 | 6 | def initialize(properties, url=nil) 7 | super(properties, url) 8 | end 9 | 10 | def kind 11 | 'note' 12 | end 13 | 14 | end 15 | -------------------------------------------------------------------------------- /lib/posts/photo.rb: -------------------------------------------------------------------------------- 1 | class Photo < Post 2 | 3 | VARIABLES_CATALOG = { 4 | syndication: 'original URL' 5 | } 6 | 7 | def initialize(properties, url=nil) 8 | super(properties, url) 9 | end 10 | 11 | def kind 12 | 'photo' 13 | end 14 | 15 | def render_variables 16 | return super().merge({ 17 | syndication: @syndication 18 | }) 19 | end 20 | 21 | end 22 | -------------------------------------------------------------------------------- /lib/posts/reply.rb: -------------------------------------------------------------------------------- 1 | class Reply < Post 2 | 3 | VARIABLES_CATALOG = { 4 | original_url: 'original post URL', 5 | title: 'title', 6 | 7 | has_photos: nil, 8 | photos: nil 9 | } 10 | 11 | def initialize(properties, url=nil) 12 | super(properties, url) 13 | if properties.key?('in-reply-to') 14 | @original_url = properties['in-reply-to'][0] 15 | else 16 | @original_url = '' 17 | end 18 | if properties.key?('name') 19 | @title = properties['name'][0] 20 | else 21 | @title = '' 22 | end 23 | end 24 | 25 | def kind 26 | 'reply' 27 | end 28 | 29 | def render_variables 30 | return super().merge({ 31 | original_url: @original_url, 32 | title: @title 33 | }) 34 | end 35 | 36 | end 37 | -------------------------------------------------------------------------------- /lib/posts/repost.rb: -------------------------------------------------------------------------------- 1 | class Repost < Post 2 | 3 | VARIABLES_CATALOG = { 4 | } 5 | 6 | def initialize(properties, url=nil) 7 | super(properties, url) 8 | end 9 | 10 | def kind 11 | 'repost' 12 | end 13 | 14 | end 15 | -------------------------------------------------------------------------------- /lib/posts/video.rb: -------------------------------------------------------------------------------- 1 | class Video < Post 2 | 3 | VARIABLES_CATALOG = { 4 | } 5 | 6 | def initialize(properties, url=nil) 7 | super(properties, url) 8 | end 9 | 10 | def kind 11 | 'video' 12 | end 13 | 14 | end 15 | -------------------------------------------------------------------------------- /models/file_system.rb: -------------------------------------------------------------------------------- 1 | # for local development and testing, fake the github api 2 | class FileSystem 3 | 4 | def contents(repo, opts) 5 | path = File.join(content_path, opts[:path]) 6 | begin 7 | content = File.read(path) 8 | rescue 9 | return 10 | end 11 | content_encoded = Base64.encode64(content) 12 | OpenStruct.new({ 13 | 'content' => content_encoded, 14 | 'sha' => 'fake_sha' 15 | }) 16 | end 17 | 18 | def create_contents(repo, filename, message, content) 19 | 20 | puts "👀 #{content}" 21 | 22 | path = File.join(content_path, filename) 23 | FileUtils.mkdir_p(File.dirname(path)) 24 | File.write(path, content) 25 | end 26 | 27 | def update_contents(repo, filename, message, sha, content) 28 | create_contents(repo, filename, message, content) 29 | end 30 | 31 | def upload(filename, file) 32 | path = File.join(content_path, filename) 33 | FileUtils.mkdir_p(File.dirname(path)) 34 | File.write(path, file) 35 | end 36 | 37 | def content_path 38 | "#{File.dirname(__FILE__)}/../../content" 39 | end 40 | 41 | end 42 | -------------------------------------------------------------------------------- /models/flow.rb: -------------------------------------------------------------------------------- 1 | class Flow < Sequel::Model 2 | 3 | require 'mustermann' 4 | 5 | many_to_one :site 6 | many_to_one :store 7 | many_to_one :media_store, class: :Store 8 | 9 | def name 10 | if post_kind 11 | return post_kind.capitalize 12 | else 13 | return "Files" 14 | end 15 | end 16 | 17 | def url_pattern 18 | return Mustermann.new(url_template) # type: :sinatra 19 | end 20 | def path_pattern 21 | return Mustermann.new(path_template) # type: :sinatra 22 | end 23 | def media_url_pattern 24 | return Mustermann.new(media_url_template) # type: :sinatra 25 | end 26 | def media_path_pattern 27 | return Mustermann.new(media_path_template) # type: :sinatra 28 | end 29 | 30 | def url_for_post(post) 31 | begin 32 | relative_url = url_pattern.expand(:ignore, post.render_variables) 33 | return URI.join(site.url, relative_url).to_s 34 | rescue => e 35 | puts "#{e.message} #{e.backtrace.join("\n")}" 36 | raise SitewriterError.new("template", "Unable to generate post url: #{e.message}", 500) 37 | end 38 | end 39 | 40 | def file_path_for_post(post) 41 | begin 42 | return path_pattern.expand(:ignore, post.render_variables) 43 | rescue => e 44 | puts "#{e.message} #{e.backtrace.join("\n")}" 45 | raise SitewriterError.new("template", "Unable to generate file path: #{e.message}", 500) 46 | end 47 | end 48 | 49 | def file_content_for_post(post) 50 | begin 51 | return Mustache.render(content_template, post.render_variables).encode(universal_newline: true) 52 | rescue => e 53 | puts "#{e.message} #{e.backtrace.join("\n")}" 54 | raise SitewriterError.new("template", "Unable to apply content template: #{e.message}", 500) 55 | end 56 | end 57 | 58 | def store_post(post) 59 | store.put(file_path_for_post(post), file_content_for_post(post), post_kind) 60 | return url_for_post(post) 61 | end 62 | 63 | def url_for_media(media) 64 | relative_url = media_url_pattern.expand(:ignore, media.render_variables) 65 | return URI.join(site.url, relative_url).to_s 66 | end 67 | 68 | def file_path_for_media(media) 69 | return media_path_pattern.expand(:ignore, media.render_variables) 70 | end 71 | 72 | def store_file(media) 73 | media_store.upload(file_path_for_media(media), media.file, "file") 74 | return url_for_media(media) 75 | end 76 | 77 | def attach_photo_url(post, url) 78 | # TODO: allow alt text in hash for JSON (spec 3.3.2) 79 | post.attach_url(:photo, url) 80 | end 81 | 82 | def attach_photo_media(post, media) 83 | file_flow = site.file_flow 84 | url = file_flow.store_file(media) 85 | post.attach_url(:photo, url) 86 | end 87 | 88 | end 89 | -------------------------------------------------------------------------------- /models/github.rb: -------------------------------------------------------------------------------- 1 | # module Transformative 2 | class Github < Store 3 | 4 | def type_desc 5 | return "GitHub" 6 | end 7 | 8 | def name 9 | return "GitHub (#{github_full_repo})" 10 | end 11 | 12 | def put(filename, content, description="post") 13 | puts "put: filename=#{filename}" 14 | # content = JSON.pretty_generate(data) 15 | if existing = get_file(filename) 16 | unless Base64.decode64(existing['content']) == content 17 | update(existing['sha'], filename, content, description) 18 | end 19 | else 20 | create(filename, content, description) 21 | end 22 | end 23 | 24 | def create(filename, content, description) 25 | octokit.create_contents( 26 | github_full_repo, 27 | filename, 28 | "New #{description} (via sitewriter.net)", 29 | content 30 | ) 31 | end 32 | 33 | def update(sha, filename, content, description) 34 | octokit.update_contents( 35 | github_full_repo, 36 | filename, 37 | "Updated #{description} (via sitewriter.net)", 38 | sha, 39 | content 40 | ) 41 | end 42 | 43 | def upload(filename, file, description="file") 44 | octokit.create_contents( 45 | github_full_repo, 46 | filename, 47 | "New #{description} (via sitewriter.net)", 48 | {file: file} 49 | ) 50 | end 51 | 52 | # broken 53 | def get(filename) 54 | file_content = get_file_content(filename) 55 | data = JSON.parse(file_content) 56 | url = filename.sub(/\.json$/, '') 57 | klass = Post.class_from_type(data['type'][0]) 58 | klass.new(data['properties'], url) 59 | end 60 | 61 | def get_url(url) 62 | relative_url = Utils.relative_url(url) 63 | get("#{relative_url}.json") 64 | end 65 | 66 | def exists_url?(url) 67 | relative_url = Utils.relative_url(url) 68 | get_file("#{relative_url}.json") != nil 69 | end 70 | 71 | def get_file(filename) 72 | begin 73 | octokit.contents(github_full_repo, { path: filename }) 74 | rescue Octokit::NotFound 75 | end 76 | end 77 | 78 | def get_file_content(filename) 79 | base64_content = octokit.contents( 80 | github_full_repo, 81 | { path: filename } 82 | ).content 83 | Base64.decode64(base64_content) 84 | end 85 | 86 | # def webhook(commits) 87 | # commits.each do |commit| 88 | # process_files(commit['added']) if commit['added'].any? 89 | # process_files(commit['modified'], true) if commit['modified'].any? 90 | # end 91 | # end 92 | # 93 | # def process_files(files, modified=false) 94 | # files.each do |file| 95 | # file_content = get_file_content(file) 96 | # url = "/" + file.sub(/\.json$/,'') 97 | # data = JSON.parse(file_content) 98 | # klass = Post.class_from_type(data['type'][0]) 99 | # post = klass.new(data['properties'], url) 100 | # 101 | # if %w( h-entry h-event ).include?(data['type'][0]) 102 | # if modified 103 | # existing_webmention_client = 104 | # ::Webmention::Client.new(post.absolute_url) 105 | # begin 106 | # existing_webmention_client.crawl 107 | # rescue OpenURI::HTTPError 108 | # end 109 | # Cache.put(post) 110 | # existing_webmention_client.send_mentions 111 | # else 112 | # Cache.put(post) 113 | # end 114 | # ::Webmention::Client.new(post.absolute_url).send_mentions 115 | # Utils.ping_pubsubhubbub 116 | # Context.fetch_contexts(post) 117 | # else 118 | # Cache.put(post) 119 | # end 120 | # end 121 | # end 122 | 123 | def github_full_repo 124 | "#{user}/#{location}" 125 | end 126 | 127 | def octokit 128 | @octokit ||= Octokit::Client.new(access_token: key) 129 | # @octokit ||= case (ENV['RACK_ENV'] || 'development').to_sym 130 | # when :production 131 | # Octokit::Client.new(access_token: key) 132 | # else 133 | # FileSystem.new 134 | # end 135 | end 136 | 137 | end 138 | # end 139 | -------------------------------------------------------------------------------- /models/init.rb: -------------------------------------------------------------------------------- 1 | require 'sequel' 2 | # require 'will_paginate/sequel' 3 | Sequel::Database.extension(:pagination, :pg_json) 4 | Sequel.extension(:pg_array, :pg_json, :pg_json_ops) 5 | 6 | DB = Sequel.connect(ENV['DATABASE_URL']) 7 | 8 | require_relative 'site' 9 | require_relative 'flow' 10 | require_relative 'store' 11 | require_relative 'github' 12 | require_relative 'file_system' 13 | -------------------------------------------------------------------------------- /models/site.rb: -------------------------------------------------------------------------------- 1 | class Site < Sequel::Model 2 | one_to_one :default_store, class: :Store 3 | many_to_one :file_flow, class: :Flow 4 | one_to_many :flows 5 | 6 | def log(count=20) 7 | return DB[:log].where(site_id: id).reverse_order(:started_at).first(count) 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /models/store.rb: -------------------------------------------------------------------------------- 1 | class Store < Sequel::Model 2 | 3 | TYPES = { 4 | 1 => :Github 5 | } 6 | 7 | plugin :single_table_inheritance, :type_id, model_map: TYPES 8 | 9 | many_to_one :site 10 | 11 | def type_desc 12 | return "Unknown" 13 | end 14 | 15 | def name 16 | return "Invalid" 17 | end 18 | 19 | end 20 | 21 | class StoreError < SitewriterError 22 | def initialize(message) 23 | super("store", message) 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /public/css/codemirror.css: -------------------------------------------------------------------------------- 1 | /* BASICS */ 2 | 3 | .CodeMirror { 4 | /* Set height, width, borders, and global font properties here */ 5 | font-family: monospace; 6 | height: 300px; 7 | color: black; 8 | direction: ltr; 9 | } 10 | 11 | /* PADDING */ 12 | 13 | .CodeMirror-lines { 14 | padding: 4px 0; /* Vertical padding around content */ 15 | } 16 | .CodeMirror pre { 17 | padding: 0 4px; /* Horizontal padding of content */ 18 | } 19 | 20 | .CodeMirror-scrollbar-filler, .CodeMirror-gutter-filler { 21 | background-color: white; /* The little square between H and V scrollbars */ 22 | } 23 | 24 | /* GUTTER */ 25 | 26 | .CodeMirror-gutters { 27 | border-right: 1px solid #ddd; 28 | background-color: #f7f7f7; 29 | white-space: nowrap; 30 | } 31 | .CodeMirror-linenumbers {} 32 | .CodeMirror-linenumber { 33 | padding: 0 3px 0 5px; 34 | min-width: 20px; 35 | text-align: right; 36 | color: #999; 37 | white-space: nowrap; 38 | } 39 | 40 | .CodeMirror-guttermarker { color: black; } 41 | .CodeMirror-guttermarker-subtle { color: #999; } 42 | 43 | /* CURSOR */ 44 | 45 | .CodeMirror-cursor { 46 | border-left: 1px solid black; 47 | border-right: none; 48 | width: 0; 49 | } 50 | /* Shown when moving in bi-directional text */ 51 | .CodeMirror div.CodeMirror-secondarycursor { 52 | border-left: 1px solid silver; 53 | } 54 | .cm-fat-cursor .CodeMirror-cursor { 55 | width: auto; 56 | border: 0 !important; 57 | background: #7e7; 58 | } 59 | .cm-fat-cursor div.CodeMirror-cursors { 60 | z-index: 1; 61 | } 62 | .cm-fat-cursor-mark { 63 | background-color: rgba(20, 255, 20, 0.5); 64 | -webkit-animation: blink 1.06s steps(1) infinite; 65 | -moz-animation: blink 1.06s steps(1) infinite; 66 | animation: blink 1.06s steps(1) infinite; 67 | } 68 | .cm-animate-fat-cursor { 69 | width: auto; 70 | border: 0; 71 | -webkit-animation: blink 1.06s steps(1) infinite; 72 | -moz-animation: blink 1.06s steps(1) infinite; 73 | animation: blink 1.06s steps(1) infinite; 74 | background-color: #7e7; 75 | } 76 | @-moz-keyframes blink { 77 | 0% {} 78 | 50% { background-color: transparent; } 79 | 100% {} 80 | } 81 | @-webkit-keyframes blink { 82 | 0% {} 83 | 50% { background-color: transparent; } 84 | 100% {} 85 | } 86 | @keyframes blink { 87 | 0% {} 88 | 50% { background-color: transparent; } 89 | 100% {} 90 | } 91 | 92 | /* Can style cursor different in overwrite (non-insert) mode */ 93 | .CodeMirror-overwrite .CodeMirror-cursor {} 94 | 95 | .cm-tab { display: inline-block; text-decoration: inherit; } 96 | 97 | .CodeMirror-rulers { 98 | position: absolute; 99 | left: 0; right: 0; top: -50px; bottom: -20px; 100 | overflow: hidden; 101 | } 102 | .CodeMirror-ruler { 103 | border-left: 1px solid #ccc; 104 | top: 0; bottom: 0; 105 | position: absolute; 106 | } 107 | 108 | /* DEFAULT THEME */ 109 | 110 | .cm-s-default .cm-header {color: blue;} 111 | .cm-s-default .cm-quote {color: #090;} 112 | .cm-negative {color: #d44;} 113 | .cm-positive {color: #292;} 114 | .cm-header, .cm-strong {font-weight: bold;} 115 | .cm-em {font-style: italic;} 116 | .cm-link {text-decoration: underline;} 117 | .cm-strikethrough {text-decoration: line-through;} 118 | 119 | .cm-s-default .cm-keyword {color: #708;} 120 | .cm-s-default .cm-atom {color: #219;} 121 | .cm-s-default .cm-number {color: #164;} 122 | .cm-s-default .cm-def {color: #00f;} 123 | .cm-s-default .cm-variable, 124 | .cm-s-default .cm-punctuation, 125 | .cm-s-default .cm-property, 126 | .cm-s-default .cm-operator {} 127 | .cm-s-default .cm-variable-2 {color: #05a;} 128 | .cm-s-default .cm-variable-3, .cm-s-default .cm-type {color: #085;} 129 | .cm-s-default .cm-comment {color: #a50;} 130 | .cm-s-default .cm-string {color: #a11;} 131 | .cm-s-default .cm-string-2 {color: #f50;} 132 | .cm-s-default .cm-meta {color: #555;} 133 | .cm-s-default .cm-qualifier {color: #555;} 134 | .cm-s-default .cm-builtin {color: #30a;} 135 | .cm-s-default .cm-bracket {color: #997;} 136 | .cm-s-default .cm-tag {color: #170;} 137 | .cm-s-default .cm-attribute {color: #00c;} 138 | .cm-s-default .cm-hr {color: #999;} 139 | .cm-s-default .cm-link {color: #00c;} 140 | 141 | .cm-s-default .cm-error {color: #f00;} 142 | .cm-invalidchar {color: #f00;} 143 | 144 | .CodeMirror-composing { border-bottom: 2px solid; } 145 | 146 | /* Default styles for common addons */ 147 | 148 | div.CodeMirror span.CodeMirror-matchingbracket {color: #0b0;} 149 | div.CodeMirror span.CodeMirror-nonmatchingbracket {color: #a22;} 150 | .CodeMirror-matchingtag { background: rgba(255, 150, 0, .3); } 151 | .CodeMirror-activeline-background {background: #e8f2ff;} 152 | 153 | /* STOP */ 154 | 155 | /* The rest of this file contains styles related to the mechanics of 156 | the editor. You probably shouldn't touch them. */ 157 | 158 | .CodeMirror { 159 | position: relative; 160 | overflow: hidden; 161 | background: white; 162 | } 163 | 164 | .CodeMirror-scroll { 165 | overflow: scroll !important; /* Things will break if this is overridden */ 166 | /* 30px is the magic margin used to hide the element's real scrollbars */ 167 | /* See overflow: hidden in .CodeMirror */ 168 | margin-bottom: -30px; margin-right: -30px; 169 | padding-bottom: 30px; 170 | height: 100%; 171 | outline: none; /* Prevent dragging from highlighting the element */ 172 | position: relative; 173 | } 174 | .CodeMirror-sizer { 175 | position: relative; 176 | border-right: 30px solid transparent; 177 | } 178 | 179 | /* The fake, visible scrollbars. Used to force redraw during scrolling 180 | before actual scrolling happens, thus preventing shaking and 181 | flickering artifacts. */ 182 | .CodeMirror-vscrollbar, .CodeMirror-hscrollbar, .CodeMirror-scrollbar-filler, .CodeMirror-gutter-filler { 183 | position: absolute; 184 | z-index: 6; 185 | display: none; 186 | } 187 | .CodeMirror-vscrollbar { 188 | right: 0; top: 0; 189 | overflow-x: hidden; 190 | overflow-y: scroll; 191 | } 192 | .CodeMirror-hscrollbar { 193 | bottom: 0; left: 0; 194 | overflow-y: hidden; 195 | overflow-x: scroll; 196 | } 197 | .CodeMirror-scrollbar-filler { 198 | right: 0; bottom: 0; 199 | } 200 | .CodeMirror-gutter-filler { 201 | left: 0; bottom: 0; 202 | } 203 | 204 | .CodeMirror-gutters { 205 | position: absolute; left: 0; top: 0; 206 | min-height: 100%; 207 | z-index: 3; 208 | } 209 | .CodeMirror-gutter { 210 | white-space: normal; 211 | height: 100%; 212 | display: inline-block; 213 | vertical-align: top; 214 | margin-bottom: -30px; 215 | } 216 | .CodeMirror-gutter-wrapper { 217 | position: absolute; 218 | z-index: 4; 219 | background: none !important; 220 | border: none !important; 221 | } 222 | .CodeMirror-gutter-background { 223 | position: absolute; 224 | top: 0; bottom: 0; 225 | z-index: 4; 226 | } 227 | .CodeMirror-gutter-elt { 228 | position: absolute; 229 | cursor: default; 230 | z-index: 4; 231 | } 232 | .CodeMirror-gutter-wrapper ::selection { background-color: transparent } 233 | .CodeMirror-gutter-wrapper ::-moz-selection { background-color: transparent } 234 | 235 | .CodeMirror-lines { 236 | cursor: text; 237 | min-height: 1px; /* prevents collapsing before first draw */ 238 | } 239 | .CodeMirror pre { 240 | /* Reset some styles that the rest of the page might have set */ 241 | -moz-border-radius: 0; -webkit-border-radius: 0; border-radius: 0; 242 | border-width: 0; 243 | background: transparent; 244 | font-family: inherit; 245 | font-size: inherit; 246 | margin: 0; 247 | white-space: pre; 248 | word-wrap: normal; 249 | line-height: inherit; 250 | color: inherit; 251 | z-index: 2; 252 | position: relative; 253 | overflow: visible; 254 | -webkit-tap-highlight-color: transparent; 255 | -webkit-font-variant-ligatures: contextual; 256 | font-variant-ligatures: contextual; 257 | } 258 | .CodeMirror-wrap pre { 259 | word-wrap: break-word; 260 | white-space: pre-wrap; 261 | word-break: normal; 262 | } 263 | 264 | .CodeMirror-linebackground { 265 | position: absolute; 266 | left: 0; right: 0; top: 0; bottom: 0; 267 | z-index: 0; 268 | } 269 | 270 | .CodeMirror-linewidget { 271 | position: relative; 272 | z-index: 2; 273 | padding: 0.1px; /* Force widget margins to stay inside of the container */ 274 | } 275 | 276 | .CodeMirror-widget {} 277 | 278 | .CodeMirror-rtl pre { direction: rtl; } 279 | 280 | .CodeMirror-code { 281 | outline: none; 282 | } 283 | 284 | /* Force content-box sizing for the elements where we expect it */ 285 | .CodeMirror-scroll, 286 | .CodeMirror-sizer, 287 | .CodeMirror-gutter, 288 | .CodeMirror-gutters, 289 | .CodeMirror-linenumber { 290 | -moz-box-sizing: content-box; 291 | box-sizing: content-box; 292 | } 293 | 294 | .CodeMirror-measure { 295 | position: absolute; 296 | width: 100%; 297 | height: 0; 298 | overflow: hidden; 299 | visibility: hidden; 300 | } 301 | 302 | .CodeMirror-cursor { 303 | position: absolute; 304 | pointer-events: none; 305 | } 306 | .CodeMirror-measure pre { position: static; } 307 | 308 | div.CodeMirror-cursors { 309 | visibility: hidden; 310 | position: relative; 311 | z-index: 3; 312 | } 313 | div.CodeMirror-dragcursors { 314 | visibility: visible; 315 | } 316 | 317 | .CodeMirror-focused div.CodeMirror-cursors { 318 | visibility: visible; 319 | } 320 | 321 | .CodeMirror-selected { background: #d9d9d9; } 322 | .CodeMirror-focused .CodeMirror-selected { background: #d7d4f0; } 323 | .CodeMirror-crosshair { cursor: crosshair; } 324 | .CodeMirror-line::selection, .CodeMirror-line > span::selection, .CodeMirror-line > span > span::selection { background: #d7d4f0; } 325 | .CodeMirror-line::-moz-selection, .CodeMirror-line > span::-moz-selection, .CodeMirror-line > span > span::-moz-selection { background: #d7d4f0; } 326 | 327 | .cm-searching { 328 | background-color: #ffa; 329 | background-color: rgba(255, 255, 0, .4); 330 | } 331 | 332 | /* Used to force a border model for a node */ 333 | .cm-force-border { padding-right: .1px; } 334 | 335 | @media print { 336 | /* Hide the cursor when printing */ 337 | .CodeMirror div.CodeMirror-cursors { 338 | visibility: hidden; 339 | } 340 | } 341 | 342 | /* See issue #2901 */ 343 | .cm-tab-wrap-hack:after { content: ''; } 344 | 345 | /* Help users use markselection to safely style text background */ 346 | span.CodeMirror-selectedtext { background: none; } 347 | -------------------------------------------------------------------------------- /public/css/milligram.css: -------------------------------------------------------------------------------- 1 | /*! 2 | * Milligram v1.3.0 3 | * https://milligram.github.io 4 | * 5 | * Copyright (c) 2017 CJ Patoilo 6 | * Licensed under the MIT license 7 | * 8 | * Colors modified for Sitewriter 9 | */ 10 | 11 | *, 12 | *:after, 13 | *:before { 14 | box-sizing: inherit; 15 | } 16 | 17 | html { 18 | box-sizing: border-box; 19 | font-size: 62.5%; 20 | } 21 | 22 | body { 23 | color: #606c76; 24 | font-family: 'Helvetica Neue', 'Helvetica', 'Arial', sans-serif; 25 | font-size: 1.6em; 26 | font-weight: 300; 27 | letter-spacing: .01em; 28 | line-height: 1.6; 29 | } 30 | 31 | blockquote { 32 | border-left: 0.3rem solid #d1d1d1; 33 | margin-left: 0; 34 | margin-right: 0; 35 | padding: 1rem 1.5rem; 36 | } 37 | 38 | blockquote *:last-child { 39 | margin-bottom: 0; 40 | } 41 | 42 | .button, 43 | button, 44 | input[type='button'], 45 | input[type='reset'], 46 | input[type='submit'] { 47 | background-color: #2980B9; 48 | border: 0.1rem solid #2980B9; 49 | border-radius: .4rem; 50 | color: #fff; 51 | cursor: pointer; 52 | display: inline-block; 53 | font-size: 1.1rem; 54 | font-weight: 700; 55 | height: 3.8rem; 56 | letter-spacing: .1rem; 57 | line-height: 3.8rem; 58 | padding: 0 3.0rem; 59 | text-align: center; 60 | text-decoration: none; 61 | text-transform: uppercase; 62 | white-space: nowrap; 63 | } 64 | 65 | .button:focus, .button:hover, 66 | button:focus, 67 | button:hover, 68 | input[type='button']:focus, 69 | input[type='button']:hover, 70 | input[type='reset']:focus, 71 | input[type='reset']:hover, 72 | input[type='submit']:focus, 73 | input[type='submit']:hover { 74 | background-color: #606c76; 75 | border-color: #606c76; 76 | color: #fff; 77 | outline: 0; 78 | } 79 | 80 | .button[disabled], 81 | button[disabled], 82 | input[type='button'][disabled], 83 | input[type='reset'][disabled], 84 | input[type='submit'][disabled] { 85 | cursor: default; 86 | opacity: .5; 87 | } 88 | 89 | .button[disabled]:focus, .button[disabled]:hover, 90 | button[disabled]:focus, 91 | button[disabled]:hover, 92 | input[type='button'][disabled]:focus, 93 | input[type='button'][disabled]:hover, 94 | input[type='reset'][disabled]:focus, 95 | input[type='reset'][disabled]:hover, 96 | input[type='submit'][disabled]:focus, 97 | input[type='submit'][disabled]:hover { 98 | background-color: #2980B9; 99 | border-color: #2980B9; 100 | } 101 | 102 | .button.button-outline, 103 | button.button-outline, 104 | input[type='button'].button-outline, 105 | input[type='reset'].button-outline, 106 | input[type='submit'].button-outline { 107 | background-color: transparent; 108 | color: #2980B9; 109 | } 110 | 111 | .button.button-outline:focus, .button.button-outline:hover, 112 | button.button-outline:focus, 113 | button.button-outline:hover, 114 | input[type='button'].button-outline:focus, 115 | input[type='button'].button-outline:hover, 116 | input[type='reset'].button-outline:focus, 117 | input[type='reset'].button-outline:hover, 118 | input[type='submit'].button-outline:focus, 119 | input[type='submit'].button-outline:hover { 120 | background-color: transparent; 121 | border-color: #606c76; 122 | color: #606c76; 123 | } 124 | 125 | .button.button-outline[disabled]:focus, .button.button-outline[disabled]:hover, 126 | button.button-outline[disabled]:focus, 127 | button.button-outline[disabled]:hover, 128 | input[type='button'].button-outline[disabled]:focus, 129 | input[type='button'].button-outline[disabled]:hover, 130 | input[type='reset'].button-outline[disabled]:focus, 131 | input[type='reset'].button-outline[disabled]:hover, 132 | input[type='submit'].button-outline[disabled]:focus, 133 | input[type='submit'].button-outline[disabled]:hover { 134 | border-color: inherit; 135 | color: #2980B9; 136 | } 137 | 138 | .button.button-clear, 139 | button.button-clear, 140 | input[type='button'].button-clear, 141 | input[type='reset'].button-clear, 142 | input[type='submit'].button-clear { 143 | background-color: transparent; 144 | border-color: transparent; 145 | color: #2980B9; 146 | } 147 | 148 | .button.button-clear:focus, .button.button-clear:hover, 149 | button.button-clear:focus, 150 | button.button-clear:hover, 151 | input[type='button'].button-clear:focus, 152 | input[type='button'].button-clear:hover, 153 | input[type='reset'].button-clear:focus, 154 | input[type='reset'].button-clear:hover, 155 | input[type='submit'].button-clear:focus, 156 | input[type='submit'].button-clear:hover { 157 | background-color: transparent; 158 | border-color: transparent; 159 | color: #606c76; 160 | } 161 | 162 | .button.button-clear[disabled]:focus, .button.button-clear[disabled]:hover, 163 | button.button-clear[disabled]:focus, 164 | button.button-clear[disabled]:hover, 165 | input[type='button'].button-clear[disabled]:focus, 166 | input[type='button'].button-clear[disabled]:hover, 167 | input[type='reset'].button-clear[disabled]:focus, 168 | input[type='reset'].button-clear[disabled]:hover, 169 | input[type='submit'].button-clear[disabled]:focus, 170 | input[type='submit'].button-clear[disabled]:hover { 171 | color: #2980B9; 172 | } 173 | 174 | code { 175 | background: #ECF0F1; 176 | border-radius: .4rem; 177 | font-size: 86%; 178 | margin: 0 .2rem; 179 | padding: .2rem .5rem; 180 | white-space: nowrap; 181 | } 182 | 183 | pre { 184 | background: #ECF0F1; 185 | border-left: 0.3rem solid #2980B9; 186 | overflow-y: hidden; 187 | white-space: pre-wrap; 188 | } 189 | 190 | pre > code { 191 | border-radius: 0; 192 | display: block; 193 | padding: 1rem 1.5rem; 194 | white-space: pre; 195 | } 196 | 197 | hr { 198 | border: 0; 199 | border-top: 0.1rem solid #ECF0F1; 200 | margin: 3.0rem 0; 201 | } 202 | 203 | input[type='email'], 204 | input[type='number'], 205 | input[type='password'], 206 | input[type='search'], 207 | input[type='tel'], 208 | input[type='text'], 209 | input[type='url'], 210 | textarea, 211 | select { 212 | -webkit-appearance: none; 213 | -moz-appearance: none; 214 | appearance: none; 215 | background-color: transparent; 216 | border: 0.1rem solid #d1d1d1; 217 | border-radius: .4rem; 218 | box-shadow: none; 219 | box-sizing: inherit; 220 | height: 3.8rem; 221 | padding: .6rem 1.0rem; 222 | width: 100%; 223 | } 224 | 225 | input[type='email']:focus, 226 | input[type='number']:focus, 227 | input[type='password']:focus, 228 | input[type='search']:focus, 229 | input[type='tel']:focus, 230 | input[type='text']:focus, 231 | input[type='url']:focus, 232 | textarea:focus, 233 | select:focus { 234 | border-color: #2980B9; 235 | outline: 0; 236 | } 237 | 238 | select { 239 | background: url('data:image/svg+xml;utf8,') center right no-repeat; 240 | padding-right: 3.0rem; 241 | } 242 | 243 | select:focus { 244 | background-image: url('data:image/svg+xml;utf8,'); 245 | } 246 | 247 | textarea { 248 | min-height: 6.5rem; 249 | } 250 | 251 | label, 252 | legend { 253 | display: block; 254 | font-size: 1.6rem; 255 | font-weight: 700; 256 | margin-bottom: .5rem; 257 | } 258 | 259 | fieldset { 260 | border-width: 0; 261 | padding: 0; 262 | } 263 | 264 | input[type='checkbox'], 265 | input[type='radio'] { 266 | display: inline; 267 | } 268 | 269 | .label-inline { 270 | display: inline-block; 271 | font-weight: normal; 272 | margin-left: .5rem; 273 | } 274 | 275 | .container { 276 | margin: 0 auto; 277 | max-width: 112.0rem; 278 | padding: 0 2.0rem; 279 | position: relative; 280 | width: 100%; 281 | } 282 | 283 | .row { 284 | display: flex; 285 | flex-direction: column; 286 | padding: 0; 287 | width: 100%; 288 | } 289 | 290 | .row.row-no-padding { 291 | padding: 0; 292 | } 293 | 294 | .row.row-no-padding > .column { 295 | padding: 0; 296 | } 297 | 298 | .row.row-wrap { 299 | flex-wrap: wrap; 300 | } 301 | 302 | .row.row-top { 303 | align-items: flex-start; 304 | } 305 | 306 | .row.row-bottom { 307 | align-items: flex-end; 308 | } 309 | 310 | .row.row-center { 311 | align-items: center; 312 | } 313 | 314 | .row.row-stretch { 315 | align-items: stretch; 316 | } 317 | 318 | .row.row-baseline { 319 | align-items: baseline; 320 | } 321 | 322 | .row .column { 323 | display: block; 324 | flex: 1 1 auto; 325 | margin-left: 0; 326 | max-width: 100%; 327 | width: 100%; 328 | } 329 | 330 | .row .column.column-offset-10 { 331 | margin-left: 10%; 332 | } 333 | 334 | .row .column.column-offset-20 { 335 | margin-left: 20%; 336 | } 337 | 338 | .row .column.column-offset-25 { 339 | margin-left: 25%; 340 | } 341 | 342 | .row .column.column-offset-33, .row .column.column-offset-34 { 343 | margin-left: 33.3333%; 344 | } 345 | 346 | .row .column.column-offset-50 { 347 | margin-left: 50%; 348 | } 349 | 350 | .row .column.column-offset-66, .row .column.column-offset-67 { 351 | margin-left: 66.6666%; 352 | } 353 | 354 | .row .column.column-offset-75 { 355 | margin-left: 75%; 356 | } 357 | 358 | .row .column.column-offset-80 { 359 | margin-left: 80%; 360 | } 361 | 362 | .row .column.column-offset-90 { 363 | margin-left: 90%; 364 | } 365 | 366 | .row .column.column-10 { 367 | flex: 0 0 10%; 368 | max-width: 10%; 369 | } 370 | 371 | .row .column.column-20 { 372 | flex: 0 0 20%; 373 | max-width: 20%; 374 | } 375 | 376 | .row .column.column-25 { 377 | flex: 0 0 25%; 378 | max-width: 25%; 379 | } 380 | 381 | .row .column.column-33, .row .column.column-34 { 382 | flex: 0 0 33.3333%; 383 | max-width: 33.3333%; 384 | } 385 | 386 | .row .column.column-40 { 387 | flex: 0 0 40%; 388 | max-width: 40%; 389 | } 390 | 391 | .row .column.column-50 { 392 | flex: 0 0 50%; 393 | max-width: 50%; 394 | } 395 | 396 | .row .column.column-60 { 397 | flex: 0 0 60%; 398 | max-width: 60%; 399 | } 400 | 401 | .row .column.column-66, .row .column.column-67 { 402 | flex: 0 0 66.6666%; 403 | max-width: 66.6666%; 404 | } 405 | 406 | .row .column.column-75 { 407 | flex: 0 0 75%; 408 | max-width: 75%; 409 | } 410 | 411 | .row .column.column-80 { 412 | flex: 0 0 80%; 413 | max-width: 80%; 414 | } 415 | 416 | .row .column.column-90 { 417 | flex: 0 0 90%; 418 | max-width: 90%; 419 | } 420 | 421 | .row .column .column-top { 422 | align-self: flex-start; 423 | } 424 | 425 | .row .column .column-bottom { 426 | align-self: flex-end; 427 | } 428 | 429 | .row .column .column-center { 430 | -ms-grid-row-align: center; 431 | align-self: center; 432 | } 433 | 434 | @media (min-width: 40rem) { 435 | .row { 436 | flex-direction: row; 437 | margin-left: -1.0rem; 438 | width: calc(100% + 2.0rem); 439 | } 440 | .row .column { 441 | margin-bottom: inherit; 442 | padding: 0 1.0rem; 443 | } 444 | } 445 | 446 | a { 447 | color: #2980B9; 448 | text-decoration: none; 449 | } 450 | 451 | a:focus, a:hover { 452 | color: #606c76; 453 | } 454 | 455 | dl, 456 | ol, 457 | ul { 458 | list-style: none; 459 | margin-top: 0; 460 | padding-left: 0; 461 | } 462 | 463 | dl dl, 464 | dl ol, 465 | dl ul, 466 | ol dl, 467 | ol ol, 468 | ol ul, 469 | ul dl, 470 | ul ol, 471 | ul ul { 472 | font-size: 90%; 473 | margin: 1.5rem 0 1.5rem 3.0rem; 474 | } 475 | 476 | ol { 477 | list-style: decimal inside; 478 | } 479 | 480 | ul { 481 | list-style: circle inside; 482 | } 483 | 484 | .button, 485 | button, 486 | dd, 487 | dt, 488 | li { 489 | margin-bottom: 1.0rem; 490 | } 491 | 492 | fieldset, 493 | input, 494 | select, 495 | textarea { 496 | margin-bottom: 1.5rem; 497 | } 498 | 499 | blockquote, 500 | dl, 501 | figure, 502 | form, 503 | ol, 504 | p, 505 | pre, 506 | table, 507 | ul { 508 | margin-bottom: 2.5rem; 509 | } 510 | 511 | table { 512 | border-spacing: 0; 513 | width: 100%; 514 | } 515 | 516 | td, 517 | th { 518 | border-bottom: 0.1rem solid #e1e1e1; 519 | padding: 1.2rem 1.5rem; 520 | text-align: left; 521 | } 522 | 523 | td:first-child, 524 | th:first-child { 525 | padding-left: 0; 526 | } 527 | 528 | td:last-child, 529 | th:last-child { 530 | padding-right: 0; 531 | } 532 | 533 | b, 534 | strong { 535 | font-weight: bold; 536 | } 537 | 538 | p { 539 | margin-top: 0; 540 | } 541 | 542 | h1, 543 | h2, 544 | h3, 545 | h4, 546 | h5, 547 | h6 { 548 | font-weight: 300; 549 | letter-spacing: -.1rem; 550 | margin-bottom: 2.0rem; 551 | margin-top: 0; 552 | } 553 | 554 | h1 { 555 | font-size: 4.6rem; 556 | line-height: 1.2; 557 | } 558 | 559 | h2 { 560 | font-size: 3.6rem; 561 | line-height: 1.25; 562 | } 563 | 564 | h3 { 565 | font-size: 2.8rem; 566 | line-height: 1.3; 567 | } 568 | 569 | h4 { 570 | font-size: 2.2rem; 571 | letter-spacing: -.08rem; 572 | line-height: 1.35; 573 | } 574 | 575 | h5 { 576 | font-size: 1.8rem; 577 | letter-spacing: -.05rem; 578 | line-height: 1.5; 579 | } 580 | 581 | h6 { 582 | font-size: 1.6rem; 583 | letter-spacing: 0; 584 | line-height: 1.4; 585 | } 586 | 587 | img { 588 | max-width: 100%; 589 | } 590 | 591 | .clearfix:after { 592 | clear: both; 593 | content: ' '; 594 | display: table; 595 | } 596 | 597 | .float-left { 598 | float: left; 599 | } 600 | 601 | .float-right { 602 | float: right; 603 | } 604 | -------------------------------------------------------------------------------- /public/css/normalize.css: -------------------------------------------------------------------------------- 1 | /*! normalize.css v8.0.0 | MIT License | github.com/necolas/normalize.css */ 2 | 3 | /* Document 4 | ========================================================================== */ 5 | 6 | /** 7 | * 1. Correct the line height in all browsers. 8 | * 2. Prevent adjustments of font size after orientation changes in iOS. 9 | */ 10 | 11 | html { 12 | line-height: 1.15; /* 1 */ 13 | -webkit-text-size-adjust: 100%; /* 2 */ 14 | } 15 | 16 | /* Sections 17 | ========================================================================== */ 18 | 19 | /** 20 | * Remove the margin in all browsers. 21 | */ 22 | 23 | body { 24 | margin: 0; 25 | } 26 | 27 | /** 28 | * Correct the font size and margin on `h1` elements within `section` and 29 | * `article` contexts in Chrome, Firefox, and Safari. 30 | */ 31 | 32 | h1 { 33 | font-size: 2em; 34 | margin: 0.67em 0; 35 | } 36 | 37 | /* Grouping content 38 | ========================================================================== */ 39 | 40 | /** 41 | * 1. Add the correct box sizing in Firefox. 42 | * 2. Show the overflow in Edge and IE. 43 | */ 44 | 45 | hr { 46 | box-sizing: content-box; /* 1 */ 47 | height: 0; /* 1 */ 48 | overflow: visible; /* 2 */ 49 | } 50 | 51 | /** 52 | * 1. Correct the inheritance and scaling of font size in all browsers. 53 | * 2. Correct the odd `em` font sizing in all browsers. 54 | */ 55 | 56 | pre { 57 | font-family: monospace, monospace; /* 1 */ 58 | font-size: 1em; /* 2 */ 59 | } 60 | 61 | /* Text-level semantics 62 | ========================================================================== */ 63 | 64 | /** 65 | * Remove the gray background on active links in IE 10. 66 | */ 67 | 68 | a { 69 | background-color: transparent; 70 | } 71 | 72 | /** 73 | * 1. Remove the bottom border in Chrome 57- 74 | * 2. Add the correct text decoration in Chrome, Edge, IE, Opera, and Safari. 75 | */ 76 | 77 | abbr[title] { 78 | border-bottom: none; /* 1 */ 79 | text-decoration: underline; /* 2 */ 80 | text-decoration: underline dotted; /* 2 */ 81 | } 82 | 83 | /** 84 | * Add the correct font weight in Chrome, Edge, and Safari. 85 | */ 86 | 87 | b, 88 | strong { 89 | font-weight: bolder; 90 | } 91 | 92 | /** 93 | * 1. Correct the inheritance and scaling of font size in all browsers. 94 | * 2. Correct the odd `em` font sizing in all browsers. 95 | */ 96 | 97 | code, 98 | kbd, 99 | samp { 100 | font-family: monospace, monospace; /* 1 */ 101 | font-size: 1em; /* 2 */ 102 | } 103 | 104 | /** 105 | * Add the correct font size in all browsers. 106 | */ 107 | 108 | small { 109 | font-size: 80%; 110 | } 111 | 112 | /** 113 | * Prevent `sub` and `sup` elements from affecting the line height in 114 | * all browsers. 115 | */ 116 | 117 | sub, 118 | sup { 119 | font-size: 75%; 120 | line-height: 0; 121 | position: relative; 122 | vertical-align: baseline; 123 | } 124 | 125 | sub { 126 | bottom: -0.25em; 127 | } 128 | 129 | sup { 130 | top: -0.5em; 131 | } 132 | 133 | /* Embedded content 134 | ========================================================================== */ 135 | 136 | /** 137 | * Remove the border on images inside links in IE 10. 138 | */ 139 | 140 | img { 141 | border-style: none; 142 | } 143 | 144 | /* Forms 145 | ========================================================================== */ 146 | 147 | /** 148 | * 1. Change the font styles in all browsers. 149 | * 2. Remove the margin in Firefox and Safari. 150 | */ 151 | 152 | button, 153 | input, 154 | optgroup, 155 | select, 156 | textarea { 157 | font-family: inherit; /* 1 */ 158 | font-size: 100%; /* 1 */ 159 | line-height: 1.15; /* 1 */ 160 | margin: 0; /* 2 */ 161 | } 162 | 163 | /** 164 | * Show the overflow in IE. 165 | * 1. Show the overflow in Edge. 166 | */ 167 | 168 | button, 169 | input { /* 1 */ 170 | overflow: visible; 171 | } 172 | 173 | /** 174 | * Remove the inheritance of text transform in Edge, Firefox, and IE. 175 | * 1. Remove the inheritance of text transform in Firefox. 176 | */ 177 | 178 | button, 179 | select { /* 1 */ 180 | text-transform: none; 181 | } 182 | 183 | /** 184 | * Correct the inability to style clickable types in iOS and Safari. 185 | */ 186 | 187 | button, 188 | [type="button"], 189 | [type="reset"], 190 | [type="submit"] { 191 | -webkit-appearance: button; 192 | } 193 | 194 | /** 195 | * Remove the inner border and padding in Firefox. 196 | */ 197 | 198 | button::-moz-focus-inner, 199 | [type="button"]::-moz-focus-inner, 200 | [type="reset"]::-moz-focus-inner, 201 | [type="submit"]::-moz-focus-inner { 202 | border-style: none; 203 | padding: 0; 204 | } 205 | 206 | /** 207 | * Restore the focus styles unset by the previous rule. 208 | */ 209 | 210 | button:-moz-focusring, 211 | [type="button"]:-moz-focusring, 212 | [type="reset"]:-moz-focusring, 213 | [type="submit"]:-moz-focusring { 214 | outline: 1px dotted ButtonText; 215 | } 216 | 217 | /** 218 | * Correct the padding in Firefox. 219 | */ 220 | 221 | fieldset { 222 | padding: 0.35em 0.75em 0.625em; 223 | } 224 | 225 | /** 226 | * 1. Correct the text wrapping in Edge and IE. 227 | * 2. Correct the color inheritance from `fieldset` elements in IE. 228 | * 3. Remove the padding so developers are not caught out when they zero out 229 | * `fieldset` elements in all browsers. 230 | */ 231 | 232 | legend { 233 | box-sizing: border-box; /* 1 */ 234 | color: inherit; /* 2 */ 235 | display: table; /* 1 */ 236 | max-width: 100%; /* 1 */ 237 | padding: 0; /* 3 */ 238 | white-space: normal; /* 1 */ 239 | } 240 | 241 | /** 242 | * Add the correct vertical alignment in Chrome, Firefox, and Opera. 243 | */ 244 | 245 | progress { 246 | vertical-align: baseline; 247 | } 248 | 249 | /** 250 | * Remove the default vertical scrollbar in IE 10+. 251 | */ 252 | 253 | textarea { 254 | overflow: auto; 255 | } 256 | 257 | /** 258 | * 1. Add the correct box sizing in IE 10. 259 | * 2. Remove the padding in IE 10. 260 | */ 261 | 262 | [type="checkbox"], 263 | [type="radio"] { 264 | box-sizing: border-box; /* 1 */ 265 | padding: 0; /* 2 */ 266 | } 267 | 268 | /** 269 | * Correct the cursor style of increment and decrement buttons in Chrome. 270 | */ 271 | 272 | [type="number"]::-webkit-inner-spin-button, 273 | [type="number"]::-webkit-outer-spin-button { 274 | height: auto; 275 | } 276 | 277 | /** 278 | * 1. Correct the odd appearance in Chrome and Safari. 279 | * 2. Correct the outline style in Safari. 280 | */ 281 | 282 | [type="search"] { 283 | -webkit-appearance: textfield; /* 1 */ 284 | outline-offset: -2px; /* 2 */ 285 | } 286 | 287 | /** 288 | * Remove the inner padding in Chrome and Safari on macOS. 289 | */ 290 | 291 | [type="search"]::-webkit-search-decoration { 292 | -webkit-appearance: none; 293 | } 294 | 295 | /** 296 | * 1. Correct the inability to style clickable types in iOS and Safari. 297 | * 2. Change font properties to `inherit` in Safari. 298 | */ 299 | 300 | ::-webkit-file-upload-button { 301 | -webkit-appearance: button; /* 1 */ 302 | font: inherit; /* 2 */ 303 | } 304 | 305 | /* Interactive 306 | ========================================================================== */ 307 | 308 | /* 309 | * Add the correct display in Edge, IE 10+, and Firefox. 310 | */ 311 | 312 | details { 313 | display: block; 314 | } 315 | 316 | /* 317 | * Add the correct display in all browsers. 318 | */ 319 | 320 | summary { 321 | display: list-item; 322 | } 323 | 324 | /* Misc 325 | ========================================================================== */ 326 | 327 | /** 328 | * Add the correct display in IE 10+. 329 | */ 330 | 331 | template { 332 | display: none; 333 | } 334 | 335 | /** 336 | * Add the correct display in IE 10. 337 | */ 338 | 339 | [hidden] { 340 | display: none; 341 | } 342 | -------------------------------------------------------------------------------- /public/css/styles.css: -------------------------------------------------------------------------------- 1 | html, body { 2 | height: 100%; 3 | } 4 | body { 5 | display: flex; 6 | flex-direction: column; 7 | 8 | margin: 0; 9 | 10 | line-height: 1.5; 11 | 12 | color: black; 13 | background: white; 14 | } 15 | 16 | header { 17 | margin: 0; 18 | padding: 0 16px; 19 | padding-top: 16px; 20 | font-size: 32px; 21 | background-color: #34495E; 22 | color: #FFFFFF; 23 | } 24 | header a { 25 | text-decoration: none; 26 | color: inherit; 27 | } 28 | header .domain { 29 | float: right; 30 | padding-top: 16px; 31 | font-size: 16px; 32 | } 33 | 34 | #content { 35 | flex: 1 0 auto; 36 | margin: 0; 37 | padding: 0; 38 | } 39 | #content>* { 40 | width: 100%; 41 | padding-top: 16px; 42 | padding-bottom: 16px; 43 | padding-left: 16px; 44 | padding-right: 16px; 45 | margin: 0; 46 | } 47 | 48 | footer { 49 | flex-shrink: 0; 50 | 51 | margin: 0; 52 | padding: 16px; 53 | font-size: 16px; 54 | background-color: #34495E; 55 | color: #FFFFFF; 56 | } 57 | footer a { 58 | color: inherit; 59 | } 60 | 61 | h1, h2 { 62 | color: #34495E; 63 | } 64 | 65 | a { 66 | color: #2980B9; 67 | } 68 | 69 | .nav { 70 | background-color: #ECF0F1; 71 | color: #34495E; 72 | } 73 | .nav a { 74 | color: #34495E; 75 | } 76 | 77 | ul.activity li { 78 | list-style: none; 79 | } 80 | 81 | .warning { 82 | font-weight: bold; 83 | background-color: #E67E22; 84 | color: #FFFFFF; 85 | } 86 | 87 | .success { 88 | font-weight: bold; 89 | background-color: #27AE60; 90 | color: #FFFFFF; 91 | } 92 | 93 | .warning a, .success a { 94 | color: #FFFFFF; 95 | } 96 | 97 | .log-error { 98 | font-weight: bold; 99 | color: #E67E22; 100 | } 101 | 102 | /* copied from milligram's textarea */ 103 | .CodeMirror { 104 | background-color: transparent; 105 | border: 0.1rem solid #d1d1d1; 106 | border-radius: .4rem; 107 | box-shadow: none; 108 | box-sizing: inherit; 109 | padding: .6rem 1.0rem; 110 | width: 100%; 111 | } 112 | .CodeMirror-focused { 113 | border-color: #2980B9; 114 | } 115 | 116 | ul.template-vars { 117 | padding: 0; 118 | } 119 | 120 | ul.template-vars>li { 121 | display: inline; 122 | list-style-type: none; 123 | margin-right: 8px; 124 | 125 | background-color: #ECF0F1; 126 | font-size: 12px; 127 | height: 20px; 128 | padding: 4px 12px; 129 | border-radius: 10px; 130 | } 131 | ul.template-vars>li:hover { 132 | background-color: #BDC3C7; 133 | } 134 | 135 | .site-types th.icon { 136 | text-align: right; 137 | } 138 | .site-types th.icon img { 139 | width: 32px; 140 | height: 32px; 141 | } 142 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gerwitz/sitewriter/8553bada12e870fd1ea06ffe5e57c2fbb852404a/public/favicon.ico -------------------------------------------------------------------------------- /public/images/article.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 7 | -------------------------------------------------------------------------------- /public/images/attachment.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 7 | -------------------------------------------------------------------------------- /public/images/audio.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 7 | -------------------------------------------------------------------------------- /public/images/bookmark.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 7 | -------------------------------------------------------------------------------- /public/images/checkin.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 7 | -------------------------------------------------------------------------------- /public/images/event.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 7 | -------------------------------------------------------------------------------- /public/images/file.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 7 | -------------------------------------------------------------------------------- /public/images/like.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 8 | -------------------------------------------------------------------------------- /public/images/note.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 7 | -------------------------------------------------------------------------------- /public/images/photo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 7 | -------------------------------------------------------------------------------- /public/images/reply.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 7 | -------------------------------------------------------------------------------- /public/images/repost.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 7 | -------------------------------------------------------------------------------- /public/images/setup.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 7 | -------------------------------------------------------------------------------- /public/images/video.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 7 | -------------------------------------------------------------------------------- /public/js/codemirror/multiplex.js: -------------------------------------------------------------------------------- 1 | // CodeMirror, copyright (c) by Marijn Haverbeke and others 2 | // Distributed under an MIT license: https://codemirror.net/LICENSE 3 | 4 | (function(mod) { 5 | if (typeof exports == "object" && typeof module == "object") // CommonJS 6 | mod(require("../../lib/codemirror")); 7 | else if (typeof define == "function" && define.amd) // AMD 8 | define(["../../lib/codemirror"], mod); 9 | else // Plain browser env 10 | mod(CodeMirror); 11 | })(function(CodeMirror) { 12 | "use strict"; 13 | 14 | CodeMirror.multiplexingMode = function(outer /*, others */) { 15 | // Others should be {open, close, mode [, delimStyle] [, innerStyle]} objects 16 | var others = Array.prototype.slice.call(arguments, 1); 17 | 18 | function indexOf(string, pattern, from, returnEnd) { 19 | if (typeof pattern == "string") { 20 | var found = string.indexOf(pattern, from); 21 | return returnEnd && found > -1 ? found + pattern.length : found; 22 | } 23 | var m = pattern.exec(from ? string.slice(from) : string); 24 | return m ? m.index + from + (returnEnd ? m[0].length : 0) : -1; 25 | } 26 | 27 | return { 28 | startState: function() { 29 | return { 30 | outer: CodeMirror.startState(outer), 31 | innerActive: null, 32 | inner: null 33 | }; 34 | }, 35 | 36 | copyState: function(state) { 37 | return { 38 | outer: CodeMirror.copyState(outer, state.outer), 39 | innerActive: state.innerActive, 40 | inner: state.innerActive && CodeMirror.copyState(state.innerActive.mode, state.inner) 41 | }; 42 | }, 43 | 44 | token: function(stream, state) { 45 | if (!state.innerActive) { 46 | var cutOff = Infinity, oldContent = stream.string; 47 | for (var i = 0; i < others.length; ++i) { 48 | var other = others[i]; 49 | var found = indexOf(oldContent, other.open, stream.pos); 50 | if (found == stream.pos) { 51 | if (!other.parseDelimiters) stream.match(other.open); 52 | state.innerActive = other; 53 | 54 | // Get the outer indent, making sure to handle CodeMirror.Pass 55 | var outerIndent = 0; 56 | if (outer.indent) { 57 | var possibleOuterIndent = outer.indent(state.outer, "", ""); 58 | if (possibleOuterIndent !== CodeMirror.Pass) outerIndent = possibleOuterIndent; 59 | } 60 | 61 | state.inner = CodeMirror.startState(other.mode, outerIndent); 62 | return other.delimStyle && (other.delimStyle + " " + other.delimStyle + "-open"); 63 | } else if (found != -1 && found < cutOff) { 64 | cutOff = found; 65 | } 66 | } 67 | if (cutOff != Infinity) stream.string = oldContent.slice(0, cutOff); 68 | var outerToken = outer.token(stream, state.outer); 69 | if (cutOff != Infinity) stream.string = oldContent; 70 | return outerToken; 71 | } else { 72 | var curInner = state.innerActive, oldContent = stream.string; 73 | if (!curInner.close && stream.sol()) { 74 | state.innerActive = state.inner = null; 75 | return this.token(stream, state); 76 | } 77 | var found = curInner.close ? indexOf(oldContent, curInner.close, stream.pos, curInner.parseDelimiters) : -1; 78 | if (found == stream.pos && !curInner.parseDelimiters) { 79 | stream.match(curInner.close); 80 | state.innerActive = state.inner = null; 81 | return curInner.delimStyle && (curInner.delimStyle + " " + curInner.delimStyle + "-close"); 82 | } 83 | if (found > -1) stream.string = oldContent.slice(0, found); 84 | var innerToken = curInner.mode.token(stream, state.inner); 85 | if (found > -1) stream.string = oldContent; 86 | 87 | if (found == stream.pos && curInner.parseDelimiters) 88 | state.innerActive = state.inner = null; 89 | 90 | if (curInner.innerStyle) { 91 | if (innerToken) innerToken = innerToken + " " + curInner.innerStyle; 92 | else innerToken = curInner.innerStyle; 93 | } 94 | 95 | return innerToken; 96 | } 97 | }, 98 | 99 | indent: function(state, textAfter, line) { 100 | var mode = state.innerActive ? state.innerActive.mode : outer; 101 | if (!mode.indent) return CodeMirror.Pass; 102 | return mode.indent(state.innerActive ? state.inner : state.outer, textAfter, line); 103 | }, 104 | 105 | blankLine: function(state) { 106 | var mode = state.innerActive ? state.innerActive.mode : outer; 107 | if (mode.blankLine) { 108 | mode.blankLine(state.innerActive ? state.inner : state.outer); 109 | } 110 | if (!state.innerActive) { 111 | for (var i = 0; i < others.length; ++i) { 112 | var other = others[i]; 113 | if (other.open === "\n") { 114 | state.innerActive = other; 115 | state.inner = CodeMirror.startState(other.mode, mode.indent ? mode.indent(state.outer, "", "") : 0); 116 | } 117 | } 118 | } else if (state.innerActive.close === "\n") { 119 | state.innerActive = state.inner = null; 120 | } 121 | }, 122 | 123 | electricChars: outer.electricChars, 124 | 125 | innerMode: function(state) { 126 | return state.inner ? {state: state.inner, mode: state.innerActive.mode} : {state: state.outer, mode: outer}; 127 | } 128 | }; 129 | }; 130 | 131 | }); 132 | -------------------------------------------------------------------------------- /public/js/codemirror/yaml-frontmatter.js: -------------------------------------------------------------------------------- 1 | // CodeMirror, copyright (c) by Marijn Haverbeke and others 2 | // Distributed under an MIT license: https://codemirror.net/LICENSE 3 | 4 | (function (mod) { 5 | if (typeof exports == "object" && typeof module == "object") // CommonJS 6 | mod(require("../../lib/codemirror"), require("../yaml/yaml")) 7 | else if (typeof define == "function" && define.amd) // AMD 8 | define(["../../lib/codemirror", "../yaml/yaml"], mod) 9 | else // Plain browser env 10 | mod(CodeMirror) 11 | })(function (CodeMirror) { 12 | 13 | var START = 0, FRONTMATTER = 1, BODY = 2 14 | 15 | // a mixed mode for Markdown text with an optional YAML front matter 16 | CodeMirror.defineMode("yaml-frontmatter", function (config, parserConfig) { 17 | var yamlMode = CodeMirror.getMode(config, "yaml") 18 | var innerMode = CodeMirror.getMode(config, parserConfig && parserConfig.base || "gfm") 19 | 20 | function curMode(state) { 21 | return state.state == BODY ? innerMode : yamlMode 22 | } 23 | 24 | return { 25 | startState: function () { 26 | return { 27 | state: START, 28 | inner: CodeMirror.startState(yamlMode) 29 | } 30 | }, 31 | copyState: function (state) { 32 | return { 33 | state: state.state, 34 | inner: CodeMirror.copyState(curMode(state), state.inner) 35 | } 36 | }, 37 | token: function (stream, state) { 38 | if (state.state == START) { 39 | if (stream.match(/---/, false)) { 40 | state.state = FRONTMATTER 41 | return yamlMode.token(stream, state.inner) 42 | } else { 43 | state.state = BODY 44 | state.inner = CodeMirror.startState(innerMode) 45 | return innerMode.token(stream, state.inner) 46 | } 47 | } else if (state.state == FRONTMATTER) { 48 | var end = stream.sol() && stream.match(/---/, false) 49 | var style = yamlMode.token(stream, state.inner) 50 | if (end) { 51 | state.state = BODY 52 | state.inner = CodeMirror.startState(innerMode) 53 | } 54 | return style 55 | } else { 56 | return innerMode.token(stream, state.inner) 57 | } 58 | }, 59 | innerMode: function (state) { 60 | return {mode: curMode(state), state: state.inner} 61 | }, 62 | blankLine: function (state) { 63 | var mode = curMode(state) 64 | if (mode.blankLine) return mode.blankLine(state.inner) 65 | } 66 | } 67 | }) 68 | }); 69 | -------------------------------------------------------------------------------- /public/js/codemirror/yaml.js: -------------------------------------------------------------------------------- 1 | // CodeMirror, copyright (c) by Marijn Haverbeke and others 2 | // Distributed under an MIT license: https://codemirror.net/LICENSE 3 | 4 | (function(mod) { 5 | if (typeof exports == "object" && typeof module == "object") // CommonJS 6 | mod(require("../../lib/codemirror")); 7 | else if (typeof define == "function" && define.amd) // AMD 8 | define(["../../lib/codemirror"], mod); 9 | else // Plain browser env 10 | mod(CodeMirror); 11 | })(function(CodeMirror) { 12 | "use strict"; 13 | 14 | CodeMirror.defineMode("yaml", function() { 15 | 16 | var cons = ['true', 'false', 'on', 'off', 'yes', 'no']; 17 | var keywordRegex = new RegExp("\\b(("+cons.join(")|(")+"))$", 'i'); 18 | 19 | return { 20 | token: function(stream, state) { 21 | var ch = stream.peek(); 22 | var esc = state.escaped; 23 | state.escaped = false; 24 | /* comments */ 25 | if (ch == "#" && (stream.pos == 0 || /\s/.test(stream.string.charAt(stream.pos - 1)))) { 26 | stream.skipToEnd(); 27 | return "comment"; 28 | } 29 | 30 | if (stream.match(/^('([^']|\\.)*'?|"([^"]|\\.)*"?)/)) 31 | return "string"; 32 | 33 | if (state.literal && stream.indentation() > state.keyCol) { 34 | stream.skipToEnd(); return "string"; 35 | } else if (state.literal) { state.literal = false; } 36 | if (stream.sol()) { 37 | state.keyCol = 0; 38 | state.pair = false; 39 | state.pairStart = false; 40 | /* document start */ 41 | if(stream.match(/---/)) { return "def"; } 42 | /* document end */ 43 | if (stream.match(/\.\.\./)) { return "def"; } 44 | /* array list item */ 45 | if (stream.match(/\s*-\s+/)) { return 'meta'; } 46 | } 47 | /* inline pairs/lists */ 48 | if (stream.match(/^(\{|\}|\[|\])/)) { 49 | if (ch == '{') 50 | state.inlinePairs++; 51 | else if (ch == '}') 52 | state.inlinePairs--; 53 | else if (ch == '[') 54 | state.inlineList++; 55 | else 56 | state.inlineList--; 57 | return 'meta'; 58 | } 59 | 60 | /* list seperator */ 61 | if (state.inlineList > 0 && !esc && ch == ',') { 62 | stream.next(); 63 | return 'meta'; 64 | } 65 | /* pairs seperator */ 66 | if (state.inlinePairs > 0 && !esc && ch == ',') { 67 | state.keyCol = 0; 68 | state.pair = false; 69 | state.pairStart = false; 70 | stream.next(); 71 | return 'meta'; 72 | } 73 | 74 | /* start of value of a pair */ 75 | if (state.pairStart) { 76 | /* block literals */ 77 | if (stream.match(/^\s*(\||\>)\s*/)) { state.literal = true; return 'meta'; }; 78 | /* references */ 79 | if (stream.match(/^\s*(\&|\*)[a-z0-9\._-]+\b/i)) { return 'variable-2'; } 80 | /* numbers */ 81 | if (state.inlinePairs == 0 && stream.match(/^\s*-?[0-9\.\,]+\s?$/)) { return 'number'; } 82 | if (state.inlinePairs > 0 && stream.match(/^\s*-?[0-9\.\,]+\s?(?=(,|}))/)) { return 'number'; } 83 | /* keywords */ 84 | if (stream.match(keywordRegex)) { return 'keyword'; } 85 | } 86 | 87 | /* pairs (associative arrays) -> key */ 88 | if (!state.pair && stream.match(/^\s*(?:[,\[\]{}&*!|>'"%@`][^\s'":]|[^,\[\]{}#&*!|>'"%@`])[^#]*?(?=\s*:($|\s))/)) { 89 | state.pair = true; 90 | state.keyCol = stream.indentation(); 91 | return "atom"; 92 | } 93 | if (state.pair && stream.match(/^:\s*/)) { state.pairStart = true; return 'meta'; } 94 | 95 | /* nothing found, continue */ 96 | state.pairStart = false; 97 | state.escaped = (ch == '\\'); 98 | stream.next(); 99 | return null; 100 | }, 101 | startState: function() { 102 | return { 103 | pair: false, 104 | pairStart: false, 105 | keyCol: 0, 106 | inlinePairs: 0, 107 | inlineList: 0, 108 | literal: false, 109 | escaped: false 110 | }; 111 | }, 112 | lineComment: "#", 113 | fold: "indent" 114 | }; 115 | }); 116 | 117 | CodeMirror.defineMIME("text/x-yaml", "yaml"); 118 | CodeMirror.defineMIME("text/yaml", "yaml"); 119 | 120 | }); 121 | -------------------------------------------------------------------------------- /routes/init.rb: -------------------------------------------------------------------------------- 1 | configure do 2 | # use Rack::SSL if settings.production? 3 | 4 | # this feels like an odd hack to avoid Sinatra's natural directory structure 5 | root_path = "#{File.dirname(__FILE__)}/../" 6 | set :config_path, "#{root_path}config/" 7 | set :syndication_targets, {} 8 | set :markdown, layout_engine: :erb 9 | set :server, :puma 10 | 11 | set :views, "#{root_path}views/" 12 | end 13 | 14 | before do 15 | headers \ 16 | "Referrer-Policy" => "no-referrer", 17 | "Content-Security-Policy" => "script-src 'self' 'unsafe-inline'" 18 | end 19 | 20 | require_relative 'pages' 21 | require_relative 'manage' 22 | require_relative 'micropub' 23 | # require_relative 'webmention' 24 | -------------------------------------------------------------------------------- /routes/manage.rb: -------------------------------------------------------------------------------- 1 | class SiteWriter < Sinatra::Application 2 | helpers Sinatra::LinkHeader 3 | 4 | enable :sessions 5 | 6 | get '/syslog/?' do 7 | env = ENV['RACK_ENV'].to_sym || :development 8 | halt(404) unless (session[:domain] == 'hans.gerwitz.com') || (env == :development) 9 | @log = DB[:log].reverse_order(:started_at) 10 | erb :syslog 11 | end 12 | 13 | get '/login' do 14 | if params.key?('code') # this is probably an indieauth callback 15 | url = Auth.url_via_indieauth("#{request.scheme}://#{request.host_with_port}/", params[:code]) 16 | login_url(url) 17 | end 18 | if session[:domain] 19 | redirect "/#{session[:domain]}/" 20 | else 21 | # it didn't work 22 | redirect '/' 23 | end 24 | end 25 | 26 | get '/logout' do 27 | session.clear 28 | redirect '/' 29 | end 30 | 31 | get '/:domain' do 32 | redirect "/#{params[:domain]}/" 33 | end 34 | 35 | get '/:domain/' do 36 | @site = auth_site 37 | erb :site_status 38 | end 39 | 40 | get '/:domain/settings' do 41 | @site = auth_site 42 | erb :site_settings 43 | end 44 | 45 | post '/:domain/settings?' do 46 | @site = auth_site 47 | # puts "☣️ Updating #{@site.domain}" 48 | @site.update_fields(params, [ 49 | :timezone 50 | ]) 51 | # puts("updated timezone to #{@site.timezone}") 52 | redirect "/#{@site.domain}/settings" 53 | end 54 | 55 | get '/:domain/posting' do 56 | @site = auth_site 57 | site_flows = @site.flows_dataset 58 | @flows = Post::TYPES.map do |kind| 59 | flow = site_flows.where(post_kind: kind.to_s).exclude(store_id: nil).first 60 | if flow 61 | { 62 | kind: kind, 63 | flow: flow 64 | } 65 | else 66 | { kind: kind } 67 | end 68 | end 69 | erb :site_posting 70 | end 71 | 72 | get '/:domain/uploading' do 73 | @site = auth_site 74 | site_flows = @site.flows_dataset 75 | erb :site_uploading 76 | end 77 | 78 | get '/:domain/stores/new' do 79 | @site = auth_site 80 | if params.key?('type_id') 81 | type_id = params['type_id'].to_i 82 | else 83 | type_id = 1 # github 84 | end 85 | store_class = Store.sti_class_from_sti_key(type_id) 86 | @store = store_class.create(site_id: @site.id) 87 | erb :store_edit 88 | end 89 | 90 | get '/:domain/stores/:id' do 91 | @site = auth_site 92 | @store = Store.find(id: params[:id].to_i, site_id: @site.id) 93 | erb :store_edit 94 | end 95 | 96 | post '/:domain/stores' do 97 | @site = auth_site 98 | 99 | store = Store.first(id: params[:id].to_i, site_id: @site.id) 100 | 101 | # if params.key?('type_id') 102 | # type_id = params['type_id'].to_i 103 | # store_class = Store.sti_class_from_sti_key(type_id) 104 | # # puts "type: #{type_id}, class: #{store_class}" 105 | # store = store_class.create(site_id: @site.id) 106 | store.update_fields(params, [ 107 | :location, 108 | :user, 109 | :key 110 | ]) 111 | if params.key?('flow_id') 112 | flow_id = params['flow_id'].to_i 113 | flow = Flow.first(id: flow_id) 114 | flow.store = store 115 | flow.save 116 | end 117 | # else 118 | # raise SitewriterError.new("bad_request", "Can't POST a store without a type") 119 | # end 120 | redirect "/#{@site.domain}/posting" 121 | end 122 | 123 | get '/:domain/stores/:id/delete' do 124 | @site = auth_site 125 | @store = Store.find(id: params[:id].to_i, site_id: @site.id) 126 | @store.destroy 127 | redirect "/#{@site.domain}/posting" 128 | end 129 | 130 | get '/:domain/flows/new' do 131 | @site = auth_site 132 | @flow = Flow.find_or_create(site_id: @site.id, post_kind: params['post_kind'].to_s) 133 | if @flow.store.nil? 134 | @flow.update(store_id: @site.default_store.id) 135 | end 136 | # flow.update_fields(params, [:post_kind]) 137 | redirect "/#{@site.domain}/flows/#{@flow.id}" 138 | end 139 | 140 | get '/:domain/flows/media' do 141 | @site = auth_site 142 | @flow = @site.file_flow 143 | if @flow.nil? 144 | @flow = Flow.create(site_id: @site.id, allow_media: true, media_store_id: @site.default_store.id) 145 | @site.update(file_flow_id: @flow.id) 146 | puts "☣️ Created new file flow [#{@flow.id}] for #{@site.domain}" 147 | end 148 | # if @flow.media_store.nil? 149 | # @flow.update(media_store_id: @site.default_store.id) 150 | # end 151 | erb :flow_media 152 | end 153 | 154 | post '/:domain/flows/media' do 155 | @site = auth_site 156 | flow = @site.file_flow 157 | puts "☣️ Updating file flow #{flow.id}" 158 | flow.update(post_kind: nil) 159 | flow.update_fields(params, [ 160 | :media_path_template, 161 | :media_url_template 162 | ]) 163 | redirect "/#{@site.domain}/uploading" 164 | end 165 | 166 | 167 | get '/:domain/flows/:id' do 168 | @site = auth_site 169 | @flow = Flow.first(id: params[:id].to_i, site_id: @site.id) 170 | erb :flow_edit 171 | end 172 | 173 | post '/:domain/flows' do 174 | @site = auth_site 175 | flow = Flow.first(id: params[:id].to_i, site_id: @site.id) 176 | flow.update_fields(params, [ 177 | # :name, 178 | :path_template, 179 | :url_template, 180 | :content_template, 181 | :allow_media, 182 | :media_store_id, 183 | :media_path_template, 184 | :media_url_template 185 | # :allow_meta 186 | ]) 187 | redirect "/#{@site.domain}/posting" 188 | end 189 | 190 | get '/:domain/flows/:id/delete' do 191 | @site = auth_site 192 | @flow = Flow.find(id: params[:id].to_i, site_id: @site.id) 193 | @flow.destroy 194 | redirect "/#{@site.domain}/posting" 195 | end 196 | 197 | not_found do 198 | status 404 199 | erb :'404' 200 | end 201 | 202 | error do 203 | status 500 204 | erb :'500' 205 | end 206 | 207 | private 208 | 209 | # login with domain from url 210 | def login_url(url) 211 | domain = URI.parse(url).host.downcase 212 | @site = Site.find_or_create(domain: domain) 213 | @site.url = url 214 | @site.save 215 | session[:domain] = domain 216 | if domain == 'hans.gerwitz.com' 217 | session[:admin] = true 218 | else 219 | session[:admin] = false 220 | end 221 | end 222 | 223 | def find_site(domain = nil) 224 | domain ||= params[:domain] 225 | if domain 226 | site = Site.first(domain: domain.to_s) 227 | if site.nil? 228 | raise SitewriterError.new("bad_request", "No site found for '#{domain}'") 229 | else 230 | return site 231 | end 232 | else 233 | not_found 234 | end 235 | end 236 | 237 | def auth_site(domain = nil) 238 | # return if ENV['RACK_ENV'].to_sym == :development 239 | domain ||= params[:domain] 240 | env = ENV['RACK_ENV'] || 'development' 241 | if (domain == session[:domain]) || session[:admin] || (env.to_sym == :development) 242 | return find_site(domain) 243 | else 244 | login_site(domain) 245 | end 246 | end 247 | 248 | def login_site(domain = nil) 249 | auth_host = "indieauth.com" 250 | auth_path = "/auth" 251 | 252 | domain ||= params[:domain] 253 | auth_query = URI.encode_www_form( 254 | client_id: "#{request.scheme}://#{ request.host_with_port }/", 255 | redirect_uri: "#{request.scheme}://#{ request.host_with_port }/login", 256 | me: domain 257 | ) 258 | redirect URI::HTTPS.build( 259 | host: auth_host, 260 | path: auth_path, 261 | query: auth_query 262 | ), 302 263 | end 264 | 265 | end 266 | -------------------------------------------------------------------------------- /routes/micropub.rb: -------------------------------------------------------------------------------- 1 | class SiteWriter < Sinatra::Application 2 | 3 | post '/:domain/micropub/?' do 4 | # TODO: handle multipart requests 5 | site = find_site 6 | start_log(site) 7 | flows = site.flows_dataset 8 | # start by assuming this is a non-create action 9 | # if params.key?('action') 10 | # verify_action 11 | # require_auth 12 | # verify_url 13 | # post = Micropub.action(params) 14 | # status 204 15 | # elsif params.key?('file') 16 | if params.key?('file') 17 | # assume this a file (photo) upload 18 | @log[:request] = "#{request.media_type} (#{request.content_length} bytes)" 19 | require_auth 20 | media = Micropub.create_media(params[:file]) 21 | @log[:kind] = 'file' 22 | flow = site.file_flow 23 | @log[:flow_id] = flow.id 24 | @log[:file] = flow.file_path_for_media(media) 25 | url = flow.store_file(media) 26 | @log[:url] = url 27 | @log[:status_code] = 202 28 | write_log 29 | headers 'Location' => url 30 | status 202 31 | else 32 | # assume this is a create 33 | request.body.rewind 34 | @log[:request] = request.body.read.dump 35 | require_auth 36 | site_tz = TZInfo::Timezone.get(site.timezone) || TZInfo::Timezone.get('UTC') 37 | # @log[:timezone] = site_tz.identifier 38 | post = Micropub.create(params, site_tz) 39 | raise Micropub::TypeError.new unless post 40 | @log[:kind] = post.kind 41 | flow = flows.where(post_kind: post.kind).first 42 | raise Micropub::ContentError.new( 43 | "Not configured to write posts of kind '#{post.kind}'." 44 | ) unless flow 45 | @log[:flow_id] = flow.id 46 | if params.key?(:photo) 47 | photo_urls = handle_photos(flow, post, params[:photo]) 48 | @log[:photos] = photo_urls 49 | end 50 | @log[:file] = flow.file_path_for_post(post) 51 | url = flow.store_post(post) 52 | @log[:url] = url 53 | @log[:status_code] = 202 54 | write_log 55 | headers 'Location' => url 56 | status 202 57 | end 58 | end 59 | 60 | # TODO: syndication targets 61 | get '/:domain/micropub/?' do 62 | site = find_site 63 | # start_log(site) 64 | if params.key?('q') 65 | require_auth 66 | content_type :json 67 | case params[:q] 68 | when 'source' 69 | 70 | when 'config' 71 | { 72 | "media-endpoint" => "#{request.scheme}://#{request.host_with_port}/#{site.domain}/micropub", 73 | "syndicate-to" => [] 74 | }.to_json 75 | when 'syndicate-to' 76 | "[]" 77 | else 78 | # Silently fail if query method is not supported 79 | end 80 | else 81 | 'Micropub endpoint' 82 | end 83 | end 84 | 85 | error SitewriterError do 86 | e = env['sinatra.error'] 87 | json = { 88 | error: e.type, 89 | error_description: e.message 90 | }.to_json 91 | if @log.is_a? Hash 92 | @log[:status_code] = e.status 93 | @log[:error] = Sequel.pg_json(json) 94 | write_log 95 | end 96 | halt(e.status, { 'Content-Type' => 'application/json' }, json) 97 | end 98 | 99 | # error do 100 | # e = env['sinatra.error'] 101 | # error_description = "Unexpected server error (#{e.class})." 102 | # if @log.is_a? Hash 103 | # error_description << " Details can be found in your activity log." 104 | # @log[:status_code] = 500 105 | # log_json = { 106 | # error: e.class, 107 | # error_description: e.message, 108 | # backtrace: e.backtrace 109 | # }.to_json 110 | # @log[:error] = Sequel.pg_json(log_json) 111 | # write_log 112 | # end 113 | # json = { 114 | # error: 'server_error', 115 | # error_description: error_description 116 | # }.to_json 117 | # halt(500, { 'Content-Type' => 'application/json' }, json) 118 | # end 119 | 120 | private 121 | 122 | def handle_photos(flow, post, params) 123 | urls = [] 124 | if params.is_a?(Array) 125 | urls = params.map.with_index do |item, index| 126 | if item.is_a?(Array) 127 | handle_photos(flow, post, item) 128 | else 129 | puts "🖼🖼 #{item}" 130 | if valid_url?(item) 131 | flow.attach_photo_url(post, item) 132 | else 133 | media = Micropub.create_media(item) 134 | flow.attach_photo_media(post, media) 135 | end 136 | end 137 | end 138 | else 139 | puts "🖼🖼 #{params}" 140 | if valid_url?(params) 141 | flow.attach_photo_url(post, params) 142 | else 143 | media = Micropub.create_media(params) 144 | media.post_slug = post.slug 145 | flow.attach_photo_media(post, media) 146 | end 147 | end 148 | return urls 149 | end 150 | 151 | def valid_url?(url) 152 | puts "Is this a URL? #{url} " 153 | begin 154 | uri = URI.parse(url) 155 | puts "YES!\n" 156 | return true 157 | rescue URI::InvalidURIError 158 | puts "NO.\n" 159 | return false 160 | end 161 | end 162 | 163 | def start_log(site) 164 | # DB is defined in models/init 165 | @log = { 166 | started_at: Time.now(), 167 | site_id: site.id, 168 | ip: request.ip, 169 | user_agent: request.user_agent, 170 | properties: Sequel.pg_json(params), 171 | } 172 | end 173 | 174 | def write_log 175 | @log[:finished_at] = Time.now() 176 | DB[:log].insert(@log) 177 | end 178 | 179 | def require_auth 180 | return unless settings.production? 181 | token = request.env['HTTP_AUTHORIZATION'] || params['access_token'] || "" 182 | token.sub!(/^Bearer /,'') 183 | if token.empty? 184 | raise Auth::NoTokenError.new 185 | end 186 | scope = params.key?('action') ? params['action'] : 'post' 187 | Auth.verify_token_and_scope(token, scope) 188 | # TODO: check "me" for domain match 189 | end 190 | 191 | def verify_action 192 | valid_actions = %w( create update delete undelete ) 193 | unless valid_actions.include?(params[:action]) 194 | raise Micropub::InvalidRequestError.new( 195 | "The specified action ('#{params[:action]}') is not supported. " + 196 | "Valid actions are: #{valid_actions.join(' ')}." 197 | ) 198 | end 199 | end 200 | # 201 | # def verify_url 202 | # unless params.key?('url') && !params[:url].empty? && 203 | # Store.exists_url?(params[:url]) 204 | # raise Micropub::InvalidRequestError.new( 205 | # "The specified URL ('#{params[:url]}') could not be found." 206 | # ) 207 | # end 208 | # end 209 | 210 | # def render_syndication_targets 211 | # content_type :json 212 | # {}.to_json 213 | # end 214 | 215 | # def render_config 216 | # content_type :json 217 | # { 218 | # "media-endpoint" => "#{ENV['SITE_URL']}micropub", 219 | # "syndicate-to" => settings.syndication_targets 220 | # }.to_json 221 | # end 222 | 223 | # this is probably broken now 224 | def render_source 225 | content_type :json 226 | relative_url = Utils.relative_url(params[:url]) 227 | not_found unless post = Store.get("#{relative_url}.json") 228 | data = if params.key?('properties') 229 | properties = {} 230 | Array(params[:properties]).each do |property| 231 | if post.properties.key?(property) 232 | properties[property] = post.properties[property] 233 | end 234 | end 235 | { 'type' => [post.h_type], 'properties' => properties } 236 | else 237 | post.data 238 | end 239 | data.to_json 240 | end 241 | 242 | end 243 | -------------------------------------------------------------------------------- /routes/pages.rb: -------------------------------------------------------------------------------- 1 | class SiteWriter < Sinatra::Application 2 | get '/' do 3 | erb :index 4 | end 5 | 6 | get '/tools' do 7 | erb :tools 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /routes/webmention.rb: -------------------------------------------------------------------------------- 1 | class SiteWriter < Sinatra::Application 2 | 3 | get '/:domain/webmention' do 4 | "Webmention endpoint" 5 | end 6 | 7 | post '/:domain/webmention' do 8 | puts "Webmention params=#{params}" 9 | Webmention.receive(params[:source], params[:target]) 10 | headers 'Location' => params[:target] 11 | status 202 12 | end 13 | 14 | end 15 | -------------------------------------------------------------------------------- /script/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require "irb" 4 | require "irb/completion" 5 | 6 | env = ENV['RACK_ENV'] || 'development' 7 | env = env.to_sym 8 | 9 | require "bundler/setup" 10 | Bundler.require(:default, env) 11 | 12 | Dotenv.load unless env == :production 13 | 14 | # require_relative '../models/init' 15 | require_relative '../app' 16 | 17 | # Suppress the Sinatra `at_exit` hook 18 | set :run, false 19 | 20 | IRB.start 21 | -------------------------------------------------------------------------------- /script/migrate: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | bundle exec rake db:migrate 3 | -------------------------------------------------------------------------------- /script/server: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | bundle exec rackup 3 | -------------------------------------------------------------------------------- /views/404.erb: -------------------------------------------------------------------------------- 1 |The page or post was not found.
7 |6 | You're authenticated as <%= session[:domain] %> 7 |
8 | 11 | 12 | <% else %> 13 | 14 |16 | SiteWriter.net makes it easy to use IndieWeb services with your static site. It currently supports writing Micropub posts to GitHub repositories. 17 |
18 |19 | To get started, enable IndieAuth on your domain. (IndieAuth.com is a popular provider.) Then enter your domain here: 20 |
26 | 27 |28 | 29 | If you don't yet have a site, you can get started for free with Jonathan McGlone's guide for GitHub pages. We strongly suggest setting it up with a custom domain of your own. 30 | 31 |
32 | <% end %> 33 | 34 |
27 | |
29 | 30 | <%= post_kind.capitalize %> 31 | | 32 | <% if flow %> 33 |
34 | file template: <%= flow.path_template %>
35 | 36 | url template: <%= flow.url_template %>
37 | | 38 | Edit 39 | | 40 | <% else %> 41 |42 | Enable 43 | | 44 | | 45 | <% end %> 46 |
---|
<head>
of your homepage:
29 | <link href="<%= "#{request.scheme}://#{request.host_with_port}/#{@site.domain}/micropub" %>" rel="micropub">
30 |
42 | <% if entry[:kind] %>
43 | | 47 | 48 | <%= entry[:started_at].strftime('%Y-%m-%d %H:%M:%S') %> 49 | 50 | | 51 | <% if entry[:file] %> 52 | <%= entry[:file] %> 53 | <% end %> 54 | |
55 | <% if entry[:url] %>
56 | <%= entry[:url] %>
57 | <% else %>
58 |
59 |
62 | <% end %>
63 |
60 | |
64 |
---|
22 | |
24 | 25 | All Files 26 | | 27 |
28 | path template: <%= @site.file_flow.media_path_template %>
29 | 30 | url template: <%= @site.file_flow.media_url_template %>
31 | | 32 | Edit 33 | | 34 |
---|
6 | <%= Site[entry[:site_id]].domain %> 7 | | 8 |9 | <%= entry[:started_at].strftime('%Y-%m-%d %H:%M:%S') %> 10 | | 11 | <%= entry[:request] %> 12 | |
13 | <% if entry[:url] %>
14 | <%= entry[:url] %>
15 | <% else %>
16 | <%= entry[:error]['error'] %> error
17 | <% end %>
18 | |
19 |
---|
3 | Get IndieWeb ready with IndieWebify.Me. 4 |
5 |6 | Test your Micropub setup at Micropub.rocks. 7 |
8 | 9 |11 | Post with Micropub using Omnibear, Quill, or Micropublish. 12 |
13 |14 | Use older posting tools by emulating Wordpress with Aaron Parecki's MetaWeblog bridge at xmlrpc.p3k.io. 15 |
16 | 17 |19 | Micro.blog is a great place to add a conversation layer to your site. Sitewriter's Micropub endpoint works for posting and it should be easy for you to publish a compatible feed. It's also great for syndicating to Twitter and Facebook. 20 |
21 |22 | Syndicate your posts to, and receive webmentions from, many social sites with Bridgy. 23 |
24 |25 | Pelle Wessman's open WebMention Endpoint can received webmentions for you and use client Javascript to display them. 26 |
27 | You might also receive webmentions with Webmention.io and display them with the Jekyll plugin. 28 |
29 | 30 |32 | Import your Instagram posts with OwnYourGram. 33 |
34 |35 | Import your Swarm (Foursquare) checkins with OwnYourSwarm. 36 |
37 | --------------------------------------------------------------------------------