├── .github └── workflows │ └── tests.yml ├── .gitignore ├── .gitmodules ├── .rubocop.yml ├── CHANGELOG.md ├── Gemfile ├── Gemfile.lock ├── Guardfile ├── LICENSE ├── README.md ├── RELEASING.md ├── Rakefile ├── bin └── nesta ├── config.ru ├── config └── deploy.rb.sample ├── lib ├── nesta.rb └── nesta │ ├── app.rb │ ├── commands.rb │ ├── commands │ ├── build.rb │ ├── demo.rb │ ├── demo │ │ └── content.rb │ ├── edit.rb │ ├── new.rb │ ├── plugin.rb │ ├── plugin │ │ └── create.rb │ ├── template.rb │ ├── theme.rb │ └── theme │ │ ├── create.rb │ │ ├── enable.rb │ │ └── install.rb │ ├── config.rb │ ├── config_file.rb │ ├── env.rb │ ├── helpers.rb │ ├── models.rb │ ├── models │ ├── file_model.rb │ ├── menu.rb │ └── page.rb │ ├── navigation.rb │ ├── overrides.rb │ ├── path.rb │ ├── plugin.rb │ ├── static │ ├── assets.rb │ ├── html_file.rb │ └── site.rb │ ├── system_command.rb │ └── version.rb ├── nesta.gemspec ├── scripts └── import-from-mephisto ├── templates ├── Gemfile ├── Rakefile ├── config.ru ├── config │ ├── config.yml │ └── deploy.rb ├── index.haml ├── plugins │ ├── Gemfile │ ├── README.md │ ├── Rakefile │ ├── gitignore │ ├── lib │ │ ├── init.rb │ │ ├── required.rb │ │ └── version.rb │ └── plugin.gemspec └── themes │ ├── README.md │ ├── app.rb │ └── views │ ├── layout.haml │ ├── master.sass │ └── page.haml ├── test ├── fixtures │ └── nesta-plugin-test │ │ ├── Gemfile │ │ ├── Rakefile │ │ ├── lib │ │ ├── nesta-plugin-test.rb │ │ └── nesta-plugin-test │ │ │ ├── init.rb │ │ │ └── version.rb │ │ └── nesta-plugin-test.gemspec ├── integration │ ├── atom_feed_test.rb │ ├── commands │ │ ├── build_test.rb │ │ ├── demo │ │ │ └── content_test.rb │ │ ├── edit_test.rb │ │ ├── new_test.rb │ │ ├── plugin │ │ │ └── create_test.rb │ │ └── theme │ │ │ ├── create_test.rb │ │ │ ├── enable_test.rb │ │ │ └── install_test.rb │ ├── default_theme_test.rb │ ├── overrides_test.rb │ ├── route_handlers_test.rb │ └── sitemap_test.rb ├── integration_test_helper.rb ├── support │ ├── model_factory.rb │ ├── temporary_files.rb │ └── test_configuration.rb ├── test_helper.rb └── unit │ ├── config_test.rb │ ├── models │ ├── file_model_test.rb │ ├── menu_test.rb │ └── page_test.rb │ ├── path_test.rb │ ├── plugin_test.rb │ ├── static │ ├── assets_test.rb │ ├── html_file_test.rb │ └── site_test.rb │ └── system_command_test.rb └── views ├── analytics.haml ├── atom.haml ├── categories.haml ├── colors.sass ├── comments.haml ├── error.haml ├── feed.haml ├── footer.haml ├── header.haml ├── layout.haml ├── master.sass ├── mixins.sass ├── normalize.scss ├── not_found.haml ├── page.haml ├── page_meta.haml ├── sidebar.haml ├── sitemap.haml └── summaries.haml /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: run-tests 3 | 4 | on: [push, pull_request] 5 | 6 | jobs: 7 | test: 8 | strategy: 9 | fail-fast: false 10 | matrix: 11 | ruby: ["3.1", "3.2", "3.3", "3.4"] 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v4 15 | - uses: ruby/setup-ruby@v1 16 | with: 17 | ruby-version: ${{ matrix.ruby }} 18 | bundler-cache: true 19 | - run: bundle exec rake test 20 | env: 21 | REPORTER: default 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.gem 2 | .*.swp 3 | .DS_Store 4 | ._* 5 | .sass-cache 6 | /.rbenv-version 7 | /.ruby-version 8 | /.rvmrc 9 | /checksums 10 | /config/config.yml 11 | /config/deploy.rb 12 | /db/*.db 13 | /pkg/* 14 | /plugins 15 | /public/cache 16 | /spec/tmp 17 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gma/nesta/18d4ded5929c0bbfce7d579181d22df32a11c850/.gitmodules -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | AllCops: 2 | Exclude: 3 | - templates/plugins/Rakefile 4 | - templates/plugins/plugin.gemspec 5 | - test/fixtures/nesta-plugin-test/nesta-plugin-test.gemspec 6 | TargetRubyVersion: 3.0 7 | 8 | Layout/EmptyLineAfterGuardClause: 9 | Enabled: false 10 | 11 | Layout/FirstHashElementIndentation: 12 | Enabled: false 13 | 14 | Layout/HeredocIndentation: 15 | Enabled: false 16 | 17 | Layout/SpaceAfterNot: 18 | Enabled: false 19 | 20 | Lint/AmbiguousRegexpLiteral: 21 | Enabled: false 22 | 23 | Lint/SuppressedException: 24 | Enabled: false 25 | 26 | Lint/UnusedMethodArgument: 27 | Enabled: false 28 | 29 | Metrics/BlockLength: 30 | Enabled: false 31 | 32 | Metrics/MethodLength: 33 | Enabled: false 34 | 35 | Naming/FileName: 36 | Enabled: false 37 | 38 | Naming/HeredocDelimiterNaming: 39 | Enabled: false 40 | 41 | Style/BarePercentLiterals: 42 | Enabled: false 43 | 44 | Style/ClassAndModuleChildren: 45 | Enabled: false 46 | 47 | Style/Documentation: 48 | Enabled: false 49 | 50 | Style/FrozenStringLiteralComment: 51 | Enabled: false 52 | 53 | Style/GuardClause: 54 | Enabled: false 55 | 56 | Style/HashSyntax: 57 | EnforcedShorthandSyntax: never 58 | 59 | Style/IfUnlessModifier: 60 | Enabled: false 61 | 62 | Style/MultilineIfModifier: 63 | Enabled: false 64 | 65 | Style/MutableConstant: 66 | Enabled: false 67 | 68 | Style/NegatedIf: 69 | Enabled: false 70 | 71 | Style/Next: 72 | Enabled: false 73 | 74 | Style/ParallelAssignment: 75 | Enabled: false 76 | 77 | Style/PercentLiteralDelimiters: 78 | Enabled: false 79 | 80 | Style/RedundantParentheses: 81 | Enabled: false 82 | 83 | Style/RedundantPercentQ: 84 | Enabled: false 85 | 86 | Style/RegexpLiteral: 87 | Enabled: false 88 | 89 | Style/SignalException: 90 | Enabled: false 91 | 92 | Style/StringConcatenation: 93 | Enabled: false 94 | 95 | Style/SymbolArray: 96 | Enabled: false 97 | 98 | Style/SymbolProc: 99 | Enabled: false 100 | 101 | Style/TernaryParentheses: 102 | Enabled: false 103 | 104 | Style/TrailingCommaInHashLiteral: 105 | Enabled: false 106 | 107 | Style/WordArray: 108 | Enabled: false 109 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | # Specify your gem's dependencies in nesta.gemspec 4 | gemspec 5 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: . 3 | specs: 4 | nesta (0.18.0) 5 | RedCloth (~> 4.2) 6 | haml (>= 3.1, < 6.0) 7 | haml-contrib (>= 1.0) 8 | rack (~> 3) 9 | rake 10 | rdiscount (~> 2.1) 11 | sass-embedded (~> 1.58) 12 | sinatra (~> 4.0) 13 | tilt (~> 2.1) 14 | 15 | GEM 16 | remote: https://rubygems.org/ 17 | specs: 18 | RedCloth (4.3.4) 19 | addressable (2.8.7) 20 | public_suffix (>= 2.0.2, < 7.0) 21 | ansi (1.5.0) 22 | base64 (0.2.0) 23 | bigdecimal (3.1.8) 24 | builder (3.3.0) 25 | byebug (11.1.3) 26 | capybara (2.18.0) 27 | addressable 28 | mini_mime (>= 0.1.3) 29 | nokogiri (>= 1.3.3) 30 | rack (>= 1.0.0) 31 | rack-test (>= 0.5.4) 32 | xpath (>= 2.0, < 4.0) 33 | ffi (1.17.0) 34 | google-protobuf (4.28.3) 35 | bigdecimal 36 | rake (>= 13) 37 | haml (5.2.2) 38 | temple (>= 0.8.0) 39 | tilt 40 | haml-contrib (1.0.0.1) 41 | haml (>= 3.2.0.alpha.13) 42 | kgio (2.11.4) 43 | listen (1.3.1) 44 | rb-fsevent (>= 0.9.3) 45 | rb-inotify (>= 0.9) 46 | rb-kqueue (>= 0.2) 47 | logger (1.6.1) 48 | mini_mime (1.1.5) 49 | mini_portile2 (2.8.8) 50 | minitest (5.25.4) 51 | minitest-reporters (1.7.1) 52 | ansi 53 | builder 54 | minitest (>= 5.0) 55 | ruby-progressbar 56 | mr-sparkle (0.3.0) 57 | listen (~> 1.0) 58 | rb-fsevent (>= 0.9) 59 | rb-inotify (>= 0.8) 60 | unicorn (>= 4.5) 61 | mustermann (3.0.3) 62 | ruby2_keywords (~> 0.0.1) 63 | nokogiri (1.18.8) 64 | mini_portile2 (~> 2.8.2) 65 | racc (~> 1.4) 66 | public_suffix (6.0.1) 67 | racc (1.8.1) 68 | rack (3.1.15) 69 | rack-protection (4.1.0) 70 | base64 (>= 0.1.0) 71 | logger (>= 1.6.0) 72 | rack (>= 3.0.0, < 4) 73 | rack-session (2.1.1) 74 | base64 (>= 0.1.0) 75 | rack (>= 3.0.0) 76 | rack-test (2.1.0) 77 | rack (>= 1.3) 78 | raindrops (0.20.1) 79 | rake (13.2.1) 80 | rb-fsevent (0.11.2) 81 | rb-inotify (0.11.1) 82 | ffi (~> 1.0) 83 | rb-kqueue (0.2.8) 84 | ffi (>= 0.5.0) 85 | rdiscount (2.2.7.3) 86 | ruby-progressbar (1.13.0) 87 | ruby2_keywords (0.0.5) 88 | sass-embedded (1.80.6) 89 | google-protobuf (~> 4.28) 90 | rake (>= 13) 91 | sinatra (4.1.0) 92 | logger (>= 1.6.0) 93 | mustermann (~> 3.0) 94 | rack (>= 3.0.0, < 4) 95 | rack-protection (= 4.1.0) 96 | rack-session (>= 2.0.0, < 3) 97 | tilt (~> 2.0) 98 | temple (0.10.3) 99 | tilt (2.4.0) 100 | unicorn (6.1.0) 101 | kgio (~> 2.6) 102 | raindrops (~> 0.7) 103 | xpath (3.2.0) 104 | nokogiri (~> 1.8) 105 | 106 | PLATFORMS 107 | ruby 108 | 109 | DEPENDENCIES 110 | byebug 111 | capybara (~> 2.0) 112 | minitest (~> 5.0) 113 | minitest-reporters 114 | mr-sparkle 115 | nesta! 116 | 117 | BUNDLED WITH 118 | 2.4.6 119 | -------------------------------------------------------------------------------- /Guardfile: -------------------------------------------------------------------------------- 1 | # A sample Guardfile 2 | # More info at https://github.com/guard/guard#readme 3 | 4 | guard 'ctags-bundler' do 5 | watch(%r{^(lib)/.*\.rb$}) { 'lib' } 6 | watch('Gemfile.lock') 7 | end 8 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2008-2023 Graham Ashton 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in 11 | all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # File Based CMS and Static Site Generator 2 | 3 | Nesta is a lightweight CMS for building content sites and blogs, written in 4 | Ruby using the [Sinatra] web framework. 5 | 6 | - Write your content in [Markdown] or [Textile], in your text editor (drop into 7 | HTML or Haml if you need more control) 8 | - Files are stored in text files on your hard drive (there is no database) 9 | - Publish changes by putting these files online (Git recommended, not required) 10 | - Deploy either as a static site (SSG) or by rendering HTML on the server (SSR) 11 | 12 | [Sinatra]: http://www.sinatrarb.com/ "Sinatra" 13 | [Markdown]: http://daringfireball.net/projects/markdown/ 14 | [Textile]: http://textism.com/tools/textile/ 15 | 16 | ## Installation 17 | 18 | Begin by [installing Ruby], then the Nesta gem: 19 | 20 | $ gem install nesta 21 | 22 | Use the `nesta` command to generate a new site: 23 | 24 | $ nesta new mysite.com --git # a git repo is optional, but recommended 25 | 26 | Install a few dependencies, and you're away: 27 | 28 | $ cd mysite.com 29 | $ bundle 30 | 31 | You'll find configuration options for your site in `config/config.yml`. The 32 | defaults will work, but you'll want to tweak it before you go very far. 33 | 34 | That's it — you can launch a local web server in development mode using the 35 | `mr-sparkle` dev server: 36 | 37 | $ bundle exec mr-sparkle config.ru 38 | 39 | Point your web browser at http://localhost:8080. Start editing the files in 40 | `content/pages` (see the [docs on writing content] for full instructions). 41 | 42 | You can either [deploy it] behind a web server, or build a static version of 43 | your site: 44 | 45 | $ nesta build # but see config.yml for build settings 46 | 47 | [installing Ruby]: https://www.ruby-lang.org/en/documentation/installation/ 48 | [docs on writing content]: http://nestacms.com/docs/creating-content/ 49 | [deploy it]: https://nestacms.com/docs/deployment/ 50 | 51 | ## Support 52 | 53 | There's plenty of information on . If you need some 54 | help with anything feel free to [file an issue], or contact me on Mastodon 55 | ([@gma@hachyderm.io]). 56 | 57 | [file an issue]: https://github.com/gma/nesta/issues/new 58 | [@gma@hachyderm.io]: https://hachyderm.io/@gma 59 | [the blog]: https://nestacms.com/blog 60 | 61 | ![Tests](https://github.com/gma/nesta/actions/workflows/tests.yml/badge.svg) 62 | 63 | ## Contributing 64 | 65 | If you want to add a new feature, consider [creating an issue] so we can 66 | have a chat before you start coding. I might be able to chip in with ideas on 67 | how to approach it, or suggest that we implement it as a [plugin] (to keep Nesta 68 | itself lean and simple). 69 | 70 | [creating an issue]: https://github.com/gma/nesta/issues/new 71 | [plugin]: https://nestacms.com/docs/plugins 72 | 73 | -- Graham 74 | 75 | -------------------------------------------------------------------------------- /RELEASING.md: -------------------------------------------------------------------------------- 1 | # Releasing Nesta 2 | 3 | When it comes time to release a new version of the Nesta gem, these are 4 | the steps: 5 | 6 | 1. Check the versions of Ruby used in `.github/workflows` are up to date. If 7 | new Ruby versions need testing, update the config and re-run the build. 8 | 9 | 2. Bump the version number in `lib/nesta/version.rb`. This will cause 10 | the version number in `Gemfile.lock` to be updated too, so regenerate 11 | it now (e.g. run `bundle install`) and check them in together. 12 | 13 | 3. Update the `CHANGLOG.md` file with a summary of significant changes since 14 | the previous release. 15 | 16 | 4. Commit these changes with a commit message of 'Bump version to ' 17 | 18 | 5. Install the gem locally (`rake install`), then generate a new site 19 | with the `nesta` command. Install the demo content site, check that 20 | it runs okay locally. 21 | 22 | 6. If everything seems fine, run `rake release`. 23 | 24 | 7. Publish an announcement blog post and email the list. 25 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require 'rubygems' 2 | require 'bundler/setup' 3 | 4 | Bundler.require(:default, :test) 5 | 6 | Bundler::GemHelper.install_tasks 7 | 8 | namespace :test do 9 | task :set_load_path do 10 | $LOAD_PATH.unshift File.expand_path('test') 11 | end 12 | 13 | def load_tests(directory) 14 | Rake::FileList["test/#{directory}/*_test.rb"].each { |f| require_relative f } 15 | end 16 | 17 | task units: :set_load_path do 18 | load_tests('unit/**') 19 | end 20 | 21 | task integrations: :set_load_path do 22 | load_tests('integration/**') 23 | end 24 | end 25 | 26 | task test: 'test:set_load_path' do 27 | load_tests('**') 28 | end 29 | -------------------------------------------------------------------------------- /bin/nesta: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require 'getoptlong' 4 | 5 | require File.expand_path('../lib/nesta/commands', File.dirname(__FILE__)) 6 | 7 | module Nesta 8 | class Cli 9 | def self.usage(exitcode = 1) 10 | script = File.basename($PROGRAM_NAME) 11 | puts <<-EOF 12 | USAGE: #{script} [GLOBAL OPTIONS] [COMMAND OPTIONS] 13 | 14 | GLOBAL OPTIONS 15 | --help, -h Display this message. 16 | --version, -v Display version number. 17 | --bash-completion Output command completion setup for Bash. 18 | --zsh-completion Output command completion setup for Zsh. 19 | 20 | COMMANDS 21 | new Create a new Nesta project. 22 | edit Edit a document (path relative to content/pages). 23 | demo:content Install example pages in ./content-demo. 24 | plugin:create Create a gem called nesta-plugin-name. 25 | theme:install Install a theme from a git repository. 26 | theme:enable Make the theme active, updating config.yml. 27 | theme:create Makes a template for a new theme in ./themes. 28 | build [path] Build static copy of site (path defaults to ./dist) 29 | 30 | OPTIONS FOR new 31 | --git Create a new git repository for the project. 32 | --vlad Include config/deploy.rb. 33 | 34 | EOF 35 | exit exitcode 36 | end 37 | 38 | def self.version 39 | puts "Nesta #{Nesta::VERSION}" 40 | exit 0 41 | end 42 | 43 | def self.parse_command_line 44 | opts = GetoptLong.new( 45 | ['--bash-completion', GetoptLong::NO_ARGUMENT], 46 | ['--domain', GetoptLong::REQUIRED_ARGUMENT], 47 | ['--git', GetoptLong::NO_ARGUMENT], 48 | ['--help', '-h', GetoptLong::NO_ARGUMENT], 49 | ['--version', '-v', GetoptLong::NO_ARGUMENT], 50 | ['--vlad', GetoptLong::NO_ARGUMENT], 51 | ['--zsh-completion', GetoptLong::NO_ARGUMENT] 52 | ) 53 | options = {} 54 | opts.each do |opt, arg| 55 | case opt 56 | when '--bash-completion' 57 | bash_completion 58 | when '--help' 59 | usage(exitcode = 0) 60 | when '--version' 61 | version 62 | when '--zsh-completion' 63 | zsh_completion 64 | else 65 | options[opt.sub(/^--/, '')] = arg 66 | end 67 | end 68 | options 69 | rescue GetoptLong::InvalidOption 70 | $stderr.puts 71 | usage 72 | end 73 | 74 | def self.require_bundled_gems 75 | if File.exist?('Gemfile') 76 | require 'bundler' 77 | Bundler.setup(:default) 78 | Bundler.require(:default) 79 | end 80 | end 81 | 82 | def self.class_for_command(command) 83 | const_names = command.split(':').map { |word| word.capitalize } 84 | const_names.inject(Nesta::Commands) do |namespace, const| 85 | namespace.const_get(const) 86 | end 87 | end 88 | 89 | def self.main(options) 90 | command = ARGV.shift 91 | command.nil? && usage 92 | begin 93 | command_cls = class_for_command(command) 94 | rescue NameError 95 | usage 96 | else 97 | command_cls.new(ARGV[0], options).execute(Nesta::SystemCommand.new) 98 | end 99 | rescue RuntimeError => e 100 | $stderr.puts "ERROR: #{e}" 101 | exit 1 102 | rescue Nesta::Commands::UsageError => e 103 | $stderr.puts "ERROR: #{e}" 104 | usage 105 | end 106 | 107 | def self.bash_completion 108 | puts <<-EOF 109 | # Bash completion configuration for the nesta command. 110 | # 111 | # Install it with these commands: 112 | # 113 | # $ nesta --bash-completion > ~/.nesta-completion.sh 114 | # $ echo '[ -f ~/.nesta-completion.sh ] && source ~/.nesta-completion.sh' \ 115 | # >> ~/.bash_profile 116 | # $ source ~/.nesta-completion.sh 117 | 118 | _nesta() { 119 | local cur prev opts pages 120 | COMPREPLY=() 121 | cur="${COMP_WORDS[COMP_CWORD]}" 122 | prev="${COMP_WORDS[COMP_CWORD-1]}" 123 | 124 | opts="new 'demo:content' edit 'plugin:create' 'theme:install' 'theme:enable' 'theme:create' build" 125 | 126 | case "${cur}" in 127 | theme:*) 128 | COMPREPLY=( $(compgen -W "enable install" -- ${cur##*:}) ) 129 | return 0 130 | ;; 131 | esac 132 | 133 | case "${prev}" in 134 | edit) 135 | local pages_path="content/pages" 136 | local pages="$(find $pages_path -type f | sed s,$pages_path/,,)" 137 | COMPREPLY=( $(compgen -W "$pages" -- $cur) ) 138 | return 0 139 | ;; 140 | *) 141 | ;; 142 | esac 143 | 144 | COMPREPLY=( $(compgen -W "$opts" -- $cur) ) 145 | return 0 146 | } 147 | complete -F _nesta nesta 148 | EOF 149 | exit 0 150 | end 151 | 152 | def self.zsh_completion 153 | puts <<-EOF 154 | # Zsh completion configuration for the nesta command. 155 | # 156 | # Install it with these commands: 157 | # 158 | # $ nesta --zsh-completion > /nesta-completion.sh 159 | # 160 | # Source form somewhere in your $fpath 161 | 162 | #compdef nesta 163 | 164 | 165 | _nesta_content() { 166 | content=(`ls content/pages/**/*`) 167 | } 168 | 169 | 170 | local -a _1st_arguments 171 | _1st_arguments=( 172 | 'new:Create a new Nesta project' 173 | 'edit:Edit a document' 174 | 'demo\:content:Install example pages in ./content-demo' 175 | 'theme\:install:Install a theme from a git repository' 176 | 'theme\:enable:Make the theme active, updating config.yml' 177 | 'theme\:create:Makes a template for a new theme in ./themes' 178 | 'build\:Build static copy of site' 179 | ) 180 | 181 | local expl 182 | local -a content 183 | 184 | _arguments \ 185 | '(-h)'{-h,--help}'[Display usage]' \ 186 | '(-v)'{-v,--version}'[Display Nesta version]' \ 187 | '(--bash-completion)--bash-completion[Output command for Bash completion]' \ 188 | '(--zsh-completion)--zsh-completion[Output command for Zsh completion]' \ 189 | '*:: :->subcmds' && return 0 190 | 191 | if (( CURRENT == 1 )); then 192 | _describe -t commands "nesta subcommand" _1st_arguments 193 | return 194 | fi 195 | 196 | case "$words[1]" in 197 | edit) 198 | _nesta_content 199 | _wanted content expl 'all content' compadd -a content ;; 200 | esac 201 | EOF 202 | exit 0 203 | end 204 | end 205 | end 206 | 207 | Nesta::Cli.require_bundled_gems 208 | Nesta::Plugin.initialize_plugins 209 | Nesta::Cli.main(Nesta::Cli.parse_command_line) 210 | -------------------------------------------------------------------------------- /config.ru: -------------------------------------------------------------------------------- 1 | require 'rubygems' 2 | require 'bundler/setup' 3 | 4 | Bundler.require(:default) 5 | 6 | $LOAD_PATH.unshift(::File.expand_path('lib', ::File.dirname(__FILE__))) 7 | 8 | require 'nesta/env' 9 | Nesta::Env.root = ::File.expand_path('.', ::File.dirname(__FILE__)) 10 | 11 | require 'nesta/app' 12 | 13 | run Nesta::App 14 | -------------------------------------------------------------------------------- /config/deploy.rb.sample: -------------------------------------------------------------------------------- 1 | set :application, "nesta" 2 | set :repository, "https://github.com/gma/nesta.git" 3 | 4 | # Set :user if you want to connect (via ssh) to your server using a 5 | # different username. You will also need to include the user in :domain 6 | # (see below). 7 | # 8 | #set :user, "deploy" 9 | #set :domain, "#{user}@example.com" 10 | set :domain, "example.com" 11 | 12 | set :deploy_to, "/var/apps/#{application}" 13 | 14 | # ============================================================================ 15 | # You probably don't need to worry about anything beneath this point... 16 | # ============================================================================ 17 | 18 | require "tempfile" 19 | require "vlad" 20 | 21 | namespace :vlad do 22 | remote_task :config_yml do 23 | put "#{shared_path}/config.yml", "vlad.config.yml" do 24 | File.open(File.join(File.dirname(__FILE__), "config.yml")).read 25 | end 26 | end 27 | 28 | task :setup do 29 | Rake::Task["vlad:config_yml"].invoke 30 | end 31 | 32 | remote_task :symlink_config_yml do 33 | run "ln -s #{shared_path}/config.yml #{current_path}/config/config.yml" 34 | end 35 | 36 | remote_task :symlink_attachments do 37 | run "ln -s #{shared_path}/content/attachments #{current_path}/public/attachments" 38 | end 39 | 40 | task :update do 41 | Rake::Task["vlad:symlink_config_yml"].invoke 42 | Rake::Task["vlad:symlink_attachments"].invoke 43 | end 44 | 45 | remote_task :bundle do 46 | run "cd #{current_path} && sudo bundle install --without development test" 47 | end 48 | 49 | # Depending on how you host Nesta, you might want to swap :start_app 50 | # with :start below. The :start_app task will tell your application 51 | # server (e.g. Passenger) to restart once your new code is deployed by 52 | # :update. Passenger is the default app server; tell Vlad that you're 53 | # using a different app server in the call to Vlad.load in Rakefile. 54 | # 55 | desc "Deploy the code and restart the server" 56 | task :deploy => [:update, :start_app] 57 | 58 | # If you use bundler to manage the installation of gems on your server 59 | # you can use this definition of the deploy task instead: 60 | # 61 | # task :deploy => [:update, :bundle, :start_app] 62 | end 63 | -------------------------------------------------------------------------------- /lib/nesta.rb: -------------------------------------------------------------------------------- 1 | module Nesta 2 | def self.deprecated(name, message) 3 | $stderr.puts "DEPRECATION WARNING: #{name} is deprecated; #{message}" 4 | end 5 | 6 | def self.fail_with(message) 7 | $stderr.puts "Error: #{message}" 8 | exit 1 9 | end 10 | end 11 | 12 | require File.expand_path('nesta/plugin', File.dirname(__FILE__)) 13 | -------------------------------------------------------------------------------- /lib/nesta/app.rb: -------------------------------------------------------------------------------- 1 | require 'sinatra/base' 2 | require 'haml' 3 | 4 | require File.expand_path('../nesta', File.dirname(__FILE__)) 5 | require File.expand_path('env', File.dirname(__FILE__)) 6 | require File.expand_path('config', File.dirname(__FILE__)) 7 | require File.expand_path('models', File.dirname(__FILE__)) 8 | require File.expand_path('helpers', File.dirname(__FILE__)) 9 | require File.expand_path('navigation', File.dirname(__FILE__)) 10 | require File.expand_path('overrides', File.dirname(__FILE__)) 11 | require File.expand_path('path', File.dirname(__FILE__)) 12 | 13 | Encoding.default_external = 'utf-8' if RUBY_VERSION =~ /^1.9/ 14 | 15 | module Nesta 16 | class App < Sinatra::Base 17 | set :root, Nesta::Env.root 18 | set :views, File.expand_path('../../views', File.dirname(__FILE__)) 19 | set :haml, { format: :html5 } 20 | set :public_folder, 'public' 21 | 22 | helpers Overrides::Renderers 23 | helpers Navigation::Renderers 24 | helpers View::Helpers 25 | 26 | before do 27 | if request.path_info =~ Regexp.new('./$') 28 | redirect to(request.path_info.sub(Regexp.new('/$'), '')) 29 | end 30 | end 31 | 32 | not_found do 33 | set_common_variables 34 | haml(:not_found) 35 | end 36 | 37 | error do 38 | set_common_variables 39 | haml(:error) 40 | end unless Nesta::App.development? 41 | 42 | Overrides.load_local_app 43 | Overrides.load_theme_app 44 | 45 | get '/robots.txt' do 46 | content_type 'text/plain', charset: 'utf-8' 47 | <<~EOF 48 | # robots.txt 49 | # See http://en.wikipedia.org/wiki/Robots_exclusion_standard 50 | EOF 51 | end 52 | 53 | get '/css/:sheet.css' do 54 | content_type 'text/css', charset: 'utf-8' 55 | stylesheet(params[:sheet].to_sym) 56 | end 57 | 58 | get '/attachments/*' do |path| 59 | filename = File.join(Nesta::Config.attachment_path, path) 60 | send_file(filename, disposition: nil) 61 | end 62 | 63 | get '/articles.xml' do 64 | content_type :xml, charset: 'utf-8' 65 | set_from_config(:title, :subtitle, :author) 66 | @articles = Page.find_articles.select { |a| a.date }[0..9] 67 | haml(:atom, format: :xhtml, layout: false) 68 | end 69 | 70 | get '/sitemap.xml' do 71 | content_type :xml, charset: 'utf-8' 72 | @pages = Page.find_all.reject do |page| 73 | page.draft? or page.flagged_as?('skip-sitemap') 74 | end 75 | @last = @pages.map { |page| page.last_modified }.inject do |latest, page| 76 | (page > latest) ? page : latest 77 | end 78 | haml(:sitemap, format: :xhtml, layout: false) 79 | end 80 | 81 | get '*' do 82 | set_common_variables 83 | parts = params[:splat].map { |p| p.sub(/\/$/, '') } 84 | @page = Nesta::Page.find_by_path(File.join(parts)) 85 | raise Sinatra::NotFound if @page.nil? 86 | @title = @page.title 87 | set_from_page(:description, :keywords) 88 | haml(@page.template, layout: @page.layout) 89 | end 90 | end 91 | end 92 | 93 | Nesta::Plugin.initialize_plugins 94 | -------------------------------------------------------------------------------- /lib/nesta/commands.rb: -------------------------------------------------------------------------------- 1 | require 'erb' 2 | require 'fileutils' 3 | 4 | require File.expand_path('env', File.dirname(__FILE__)) 5 | require File.expand_path('app', File.dirname(__FILE__)) 6 | require File.expand_path('path', File.dirname(__FILE__)) 7 | require File.expand_path('system_command', File.dirname(__FILE__)) 8 | require File.expand_path('version', File.dirname(__FILE__)) 9 | 10 | require File.expand_path('commands/build', File.dirname(__FILE__)) 11 | require File.expand_path('commands/demo', File.dirname(__FILE__)) 12 | require File.expand_path('commands/edit', File.dirname(__FILE__)) 13 | require File.expand_path('commands/new', File.dirname(__FILE__)) 14 | require File.expand_path('commands/plugin', File.dirname(__FILE__)) 15 | require File.expand_path('commands/template', File.dirname(__FILE__)) 16 | require File.expand_path('commands/theme', File.dirname(__FILE__)) 17 | 18 | module Nesta 19 | module Commands 20 | class UsageError < RuntimeError; end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /lib/nesta/commands/build.rb: -------------------------------------------------------------------------------- 1 | require 'rake' 2 | 3 | require_relative '../static/assets' 4 | require_relative '../static/site' 5 | 6 | module Nesta 7 | module Commands 8 | class Build 9 | DEFAULT_DESTINATION = 'dist' 10 | 11 | attr_accessor :domain 12 | 13 | def initialize(build_dir = nil, options = {}) 14 | @build_dir = build_dir || DEFAULT_DESTINATION 15 | if @build_dir == Nesta::App.settings.public_folder 16 | raise "#{@build_dir} is already used, for assets" 17 | end 18 | @domain = options['domain'] || configured_domain_name 19 | end 20 | 21 | def configured_domain_name 22 | Nesta::Config.build.fetch('domain', 'localhost') 23 | end 24 | 25 | def execute(process) 26 | logger = Proc.new { |message| puts message } 27 | site = Nesta::Static::Site.new(@build_dir, @domain, logger) 28 | site.render_pages 29 | site.render_not_found 30 | site.render_atom_feed 31 | site.render_sitemap 32 | site.render_templated_assets 33 | Nesta::Static::Assets.new(@build_dir, logger).copy_attachments 34 | Nesta::Static::Assets.new(@build_dir, logger).copy_public_folder 35 | end 36 | end 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /lib/nesta/commands/demo.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path('demo/content', File.dirname(__FILE__)) 2 | -------------------------------------------------------------------------------- /lib/nesta/commands/demo/content.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path('../../config_file', File.dirname(__FILE__)) 2 | 3 | module Nesta 4 | module Commands 5 | module Demo 6 | class Content 7 | @demo_repository = 'https://github.com/gma/nesta-demo-content.git' 8 | class << self 9 | attr_accessor :demo_repository 10 | end 11 | 12 | def initialize(*args) 13 | @dir = 'content-demo' 14 | end 15 | 16 | def clone_or_update_repository(process) 17 | path = Nesta::Path.local(@dir) 18 | if File.exist?(path) 19 | FileUtils.cd(path) { process.run('git', 'pull', 'origin', 'master') } 20 | else 21 | process.run('git', 'clone', self.class.demo_repository, path) 22 | end 23 | end 24 | 25 | def exclude_path 26 | Nesta::Path.local('.git/info/exclude') 27 | end 28 | 29 | def in_git_repo? 30 | File.directory?(Nesta::Path.local('.git')) 31 | end 32 | 33 | def demo_repo_ignored? 34 | File.read(exclude_path).split.any? { |line| line == @dir } 35 | rescue Errno::ENOENT 36 | false 37 | end 38 | 39 | def configure_git_to_ignore_repo 40 | if in_git_repo? && ! demo_repo_ignored? 41 | FileUtils.mkdir_p(File.dirname(exclude_path)) 42 | File.open(exclude_path, 'a') { |file| file.puts @dir } 43 | end 44 | end 45 | 46 | def execute(process) 47 | clone_or_update_repository(process) 48 | configure_git_to_ignore_repo 49 | Nesta::ConfigFile.new.set_value('content', @dir) 50 | end 51 | end 52 | end 53 | end 54 | end 55 | -------------------------------------------------------------------------------- /lib/nesta/commands/edit.rb: -------------------------------------------------------------------------------- 1 | module Nesta 2 | module Commands 3 | class Edit 4 | def initialize(*args) 5 | @filename = Nesta::Config.page_path(args.shift) 6 | end 7 | 8 | def execute(process) 9 | editor = ENV.fetch('EDITOR') 10 | rescue IndexError 11 | $stderr.puts 'No editor: set EDITOR environment variable' 12 | else 13 | process.run(editor, @filename) 14 | end 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /lib/nesta/commands/new.rb: -------------------------------------------------------------------------------- 1 | module Nesta 2 | module Commands 3 | class New 4 | def initialize(*args) 5 | path = args.shift 6 | options = args.shift || {} 7 | path.nil? && (raise UsageError, 'path not specified') 8 | if File.exist?(path) 9 | raise "#{path} already exists" 10 | end 11 | @path = path 12 | @options = options 13 | end 14 | 15 | def make_directories 16 | %w[content/attachments content/pages].each do |dir| 17 | FileUtils.mkdir_p(File.join(@path, dir)) 18 | end 19 | end 20 | 21 | def have_rake_tasks? 22 | @options['vlad'] 23 | end 24 | 25 | def create_repository(process) 26 | FileUtils.cd(@path) do 27 | File.open('.gitignore', 'w') do |file| 28 | lines = %w[._* .*.swp .bundle .DS_Store .sass-cache dist] 29 | file.puts lines.join("\n") 30 | end 31 | process.run('git', 'init') 32 | process.run('git', 'add', '.') 33 | process.run('git', 'commit', '-m', 'Initial commit') 34 | end 35 | end 36 | 37 | def execute(process) 38 | make_directories 39 | templates = { 40 | 'config.ru' => "#{@path}/config.ru", 41 | 'config/config.yml' => "#{@path}/config/config.yml", 42 | 'index.haml' => "#{@path}/content/pages/index.haml", 43 | 'Gemfile' => "#{@path}/Gemfile" 44 | } 45 | templates['Rakefile'] = "#{@path}/Rakefile" if have_rake_tasks? 46 | if @options['vlad'] 47 | templates['config/deploy.rb'] = "#{@path}/config/deploy.rb" 48 | end 49 | templates.each do |src, dest| 50 | Nesta::Commands::Template.new(src).copy_to(dest, binding) 51 | end 52 | create_repository(process) if @options['git'] 53 | end 54 | end 55 | end 56 | end 57 | -------------------------------------------------------------------------------- /lib/nesta/commands/plugin.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path('plugin/create', File.dirname(__FILE__)) 2 | -------------------------------------------------------------------------------- /lib/nesta/commands/plugin/create.rb: -------------------------------------------------------------------------------- 1 | module Nesta 2 | module Commands 3 | module Plugin 4 | class Create 5 | def initialize(*args) 6 | name = args.shift 7 | name.nil? && (raise UsageError, 'name not specified') 8 | @name = name 9 | @gem_name = "nesta-plugin-#{name}" 10 | if File.exist?(@gem_name) 11 | raise "#{@gem_name} already exists" 12 | end 13 | end 14 | 15 | def lib_path(*parts) 16 | File.join(@gem_name, 'lib', *parts) 17 | end 18 | 19 | def module_name 20 | module_names.join('::') 21 | end 22 | 23 | def nested_module_definition_with_version 24 | indent_with = ' ' 25 | 26 | lines = module_names.map { |name| "module #{name}" } 27 | indent_levels = 0.upto(module_names.size - 1).to_a 28 | 29 | lines << "VERSION = '0.1.0'" 30 | indent_levels << module_names.size 31 | 32 | (module_names.size - 1).downto(0).each do |indent_level| 33 | lines << 'end' 34 | indent_levels << indent_level 35 | end 36 | 37 | code = [] 38 | lines.each_with_index do |line, i| 39 | code << indent_with * (indent_levels[i] + 2) + line 40 | end 41 | code.join("\n") 42 | end 43 | 44 | def make_directories 45 | FileUtils.mkdir_p(File.join(@gem_name, 'lib', @gem_name)) 46 | end 47 | 48 | def gem_path(path) 49 | File.join(@gem_name, path) 50 | end 51 | 52 | def execute(process) 53 | make_directories 54 | { 55 | 'plugins/README.md' => gem_path('README.md'), 56 | 'plugins/gitignore' => gem_path('.gitignore'), 57 | 'plugins/plugin.gemspec' => gem_path("#{@gem_name}.gemspec"), 58 | 'plugins/Gemfile' => gem_path('Gemfile'), 59 | 'plugins/lib/required.rb' => gem_path("lib/#{@gem_name}.rb"), 60 | 'plugins/lib/version.rb' => gem_path("lib/#{@gem_name}/version.rb"), 61 | 'plugins/lib/init.rb' => gem_path("lib/#{@gem_name}/init.rb"), 62 | 'plugins/Rakefile' => gem_path('Rakefile') 63 | }.each do |src, dest| 64 | Nesta::Commands::Template.new(src).copy_to(dest, binding) 65 | end 66 | Dir.chdir(@gem_name) do 67 | process.run('git', 'init') 68 | process.run('git', 'add', '.') 69 | end 70 | end 71 | 72 | private 73 | 74 | def module_names 75 | @name.split('-').map { |name| name.capitalize } 76 | end 77 | end 78 | end 79 | end 80 | end 81 | -------------------------------------------------------------------------------- /lib/nesta/commands/template.rb: -------------------------------------------------------------------------------- 1 | module Nesta 2 | module Commands 3 | class Template 4 | def initialize(filename) 5 | @filename = filename 6 | end 7 | 8 | def template_path 9 | dir = File.expand_path('../../../templates', File.dirname(__FILE__)) 10 | File.join(dir, @filename) 11 | end 12 | 13 | def copy_to(dest, context) 14 | FileUtils.mkdir_p(File.dirname(dest)) 15 | template = ERB.new(File.read(template_path), trim_mode: '-') 16 | File.open(dest, 'w') { |file| file.puts template.result(context) } 17 | end 18 | end 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /lib/nesta/commands/theme.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path('theme/create', File.dirname(__FILE__)) 2 | require File.expand_path('theme/enable', File.dirname(__FILE__)) 3 | require File.expand_path('theme/install', File.dirname(__FILE__)) 4 | -------------------------------------------------------------------------------- /lib/nesta/commands/theme/create.rb: -------------------------------------------------------------------------------- 1 | module Nesta 2 | module Commands 3 | module Theme 4 | class Create 5 | def initialize(*args) 6 | name = args.shift 7 | name.nil? && (raise UsageError, 'name not specified') 8 | @name = name 9 | @theme_path = Nesta::Path.themes(@name) 10 | if File.exist?(@theme_path) 11 | Nesta.fail_with("#{@theme_path} already exists") 12 | end 13 | end 14 | 15 | def make_directories 16 | FileUtils.mkdir_p(File.join(@theme_path, 'public', @name)) 17 | FileUtils.mkdir_p(File.join(@theme_path, 'views')) 18 | end 19 | 20 | def execute(process) 21 | make_directories 22 | { 23 | 'themes/README.md' => "#{@theme_path}/README.md", 24 | 'themes/app.rb' => "#{@theme_path}/app.rb", 25 | 'themes/views/layout.haml' => "#{@theme_path}/views/layout.haml", 26 | 'themes/views/page.haml' => "#{@theme_path}/views/page.haml", 27 | 'themes/views/master.sass' => "#{@theme_path}/views/master.sass" 28 | }.each do |src, dest| 29 | Nesta::Commands::Template.new(src).copy_to(dest, binding) 30 | end 31 | end 32 | end 33 | end 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /lib/nesta/commands/theme/enable.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path('../../config_file', File.dirname(__FILE__)) 2 | 3 | module Nesta 4 | module Commands 5 | module Theme 6 | class Enable 7 | def initialize(*args) 8 | name = args.shift 9 | name.nil? && (raise UsageError, 'name not specified') 10 | @name = name 11 | end 12 | 13 | def execute(process) 14 | Nesta::ConfigFile.new.set_value('theme', @name) 15 | end 16 | end 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /lib/nesta/commands/theme/install.rb: -------------------------------------------------------------------------------- 1 | module Nesta 2 | module Commands 3 | module Theme 4 | class Install 5 | def initialize(*args) 6 | @url = args.shift 7 | @url.nil? && (raise UsageError, 'URL not specified') 8 | end 9 | 10 | def theme_name 11 | File.basename(@url, '.git').sub(/nesta-theme-/, '') 12 | end 13 | 14 | def execute(process) 15 | process.run('git', 'clone', @url, "themes/#{theme_name}") 16 | FileUtils.rm_rf(File.join("themes/#{theme_name}", '.git')) 17 | enable(process) 18 | end 19 | 20 | def enable(process) 21 | Enable.new(theme_name).execute(process) 22 | end 23 | end 24 | end 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /lib/nesta/config.rb: -------------------------------------------------------------------------------- 1 | require 'singleton' 2 | require 'yaml' 3 | 4 | require_relative './config_file' 5 | 6 | module Nesta 7 | class Config 8 | include Singleton 9 | 10 | class NotDefined < KeyError; end 11 | 12 | SETTINGS = %w[ 13 | author 14 | build 15 | content 16 | disqus_short_name 17 | domain 18 | google_analytics_code 19 | read_more 20 | subtitle 21 | theme 22 | title 23 | ].map(&:freeze) 24 | 25 | class << self 26 | extend Forwardable 27 | def_delegators *[:instance, :fetch].concat(SETTINGS.map(&:to_sym)) 28 | end 29 | 30 | attr_accessor :config 31 | 32 | def fetch(setting, *default) 33 | setting = setting.to_s 34 | self.config ||= read_config_file(setting) 35 | env_config = config.fetch(Nesta::App.environment.to_s, {}) 36 | env_config.fetch(setting) do 37 | config.fetch(setting) do 38 | raise NotDefined, setting 39 | end 40 | end 41 | rescue NotDefined 42 | default.empty? && raise || (return default.first) 43 | end 44 | 45 | def method_missing(method, *args) 46 | if SETTINGS.include?(method.to_s) 47 | fetch(method.to_s, nil) 48 | else 49 | super 50 | end 51 | end 52 | 53 | def respond_to_missing?(method, include_private = false) 54 | SETTINGS.include?(method.to_s) || super 55 | end 56 | 57 | def build 58 | fetch('build', {}) 59 | end 60 | 61 | def read_more 62 | fetch('read_more', 'Continue reading') 63 | end 64 | 65 | def self.content_path(basename = nil) 66 | get_path(content, basename) 67 | end 68 | 69 | def self.page_path(basename = nil) 70 | get_path(File.join(content_path, 'pages'), basename) 71 | end 72 | 73 | def self.attachment_path(basename = nil) 74 | get_path(File.join(content_path, 'attachments'), basename) 75 | end 76 | 77 | private 78 | 79 | def read_config_file(setting) 80 | YAML.safe_load(ERB.new(IO.read(Nesta::ConfigFile.path)).result) 81 | rescue Errno::ENOENT 82 | raise NotDefined, setting 83 | end 84 | 85 | def self.get_path(dirname, basename) 86 | basename.nil? ? dirname : File.join(dirname, basename) 87 | end 88 | private_class_method :get_path 89 | end 90 | end 91 | -------------------------------------------------------------------------------- /lib/nesta/config_file.rb: -------------------------------------------------------------------------------- 1 | module Nesta 2 | class ConfigFile 3 | def self.path 4 | File.expand_path('config/config.yml', Nesta::App.root) 5 | end 6 | 7 | def set_value(key, value) 8 | pattern = /^\s*#?\s*#{key}:.*/ 9 | replacement = "#{key}: #{value}" 10 | 11 | configured = false 12 | File.open(self.class.path, 'r+') do |file| 13 | output = [] 14 | file.each_line do |line| 15 | if configured 16 | output << line 17 | else 18 | output << line.sub(pattern, replacement) 19 | configured = true if line =~ pattern 20 | end 21 | end 22 | output << "#{replacement}\n" unless configured 23 | file.pos = 0 24 | file.print(output.join("\n")) 25 | file.truncate(file.pos) 26 | end 27 | end 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /lib/nesta/env.rb: -------------------------------------------------------------------------------- 1 | module Nesta 2 | class Env 3 | class << self 4 | attr_accessor :root 5 | end 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /lib/nesta/helpers.rb: -------------------------------------------------------------------------------- 1 | module Nesta 2 | module View 3 | module Helpers 4 | def set_from_config(*variables) 5 | variables.each do |var| 6 | instance_variable_set("@#{var}", Nesta::Config.send(var)) 7 | end 8 | end 9 | 10 | def set_from_page(*variables) 11 | variables.each do |var| 12 | instance_variable_set("@#{var}", @page.send(var)) 13 | end 14 | end 15 | 16 | def no_widow(text) 17 | text.split[0...-1].join(' ') + " #{text.split[-1]}" 18 | end 19 | 20 | def set_common_variables 21 | @menu_items = Nesta::Menu.for_path('/') 22 | @site_title = Nesta::Config.title 23 | set_from_config(:title, :subtitle, :google_analytics_code) 24 | @heading = @title 25 | end 26 | 27 | def absolute_urls(text) 28 | text.gsub!(/( 0 66 | haml_tag :link, href: path_to("/css/#{name}.css"), rel: 'stylesheet' 67 | end 68 | end 69 | 70 | def latest_articles(count = 8) 71 | Nesta::Page.find_articles[0..count - 1] 72 | end 73 | 74 | def article_summaries(articles) 75 | haml(:summaries, layout: false, locals: { pages: articles }) 76 | end 77 | 78 | def articles_heading 79 | @page.metadata('articles heading') || "Articles on #{@page.heading}" 80 | end 81 | 82 | # Generates the full path to a given page, taking Rack routers and 83 | # reverse proxies into account. 84 | # 85 | # Takes an options hash with a single option called `uri`. Set it 86 | # to `true` if you'd like the publicly accessible URI for the 87 | # path, rather than just the path relative to the site's root URI. 88 | # The default is `false`. 89 | # 90 | # path_to(page.abspath, uri: true) 91 | # 92 | def path_to(page_path, options = {}) 93 | host = [] 94 | if options[:uri] 95 | host << "http#{'s' if request.ssl?}://" 96 | default_port = request.ssl? ? 443 : 80 97 | include_port = request.env.include?('HTTP_X_FORWARDED_HOST') || 98 | request.port != default_port 99 | host << (include_port ? request.host_with_port : request.host) 100 | end 101 | uri_parts = [host.join('')] 102 | uri_parts << request.script_name.to_s if request.script_name 103 | uri_parts << page_path 104 | File.join(uri_parts) 105 | end 106 | end 107 | end 108 | end 109 | -------------------------------------------------------------------------------- /lib/nesta/models.rb: -------------------------------------------------------------------------------- 1 | require 'rdiscount' 2 | 3 | require_relative './models/file_model' 4 | require_relative './models/menu' 5 | require_relative './models/page' 6 | -------------------------------------------------------------------------------- /lib/nesta/models/file_model.rb: -------------------------------------------------------------------------------- 1 | def register_template_handler(class_name, *extensions) 2 | Tilt.register Tilt.const_get(class_name), *extensions 3 | rescue LoadError, NameError 4 | # Only one of the Markdown processors needs to be available, so we can 5 | # safely ignore these load errors. 6 | end 7 | 8 | register_template_handler :KramdownTemplate, 'mdown', 'md' 9 | register_template_handler :RDiscountTemplate, 'mdown', 'md' 10 | register_template_handler :RedcarpetTemplate, 'mdown', 'md' 11 | 12 | 13 | module Nesta 14 | class MetadataParseError < RuntimeError; end 15 | 16 | class FileModel 17 | FORMATS = [:mdown, :md, :haml, :textile] 18 | @@model_cache = {} 19 | @@filename_cache = {} 20 | 21 | attr_reader :filename, :mtime 22 | 23 | class CaseInsensitiveHash < Hash 24 | def [](key) 25 | super(key.to_s.downcase) 26 | end 27 | end 28 | 29 | def self.model_path(basename = nil) 30 | Nesta::Config.content_path(basename) 31 | end 32 | 33 | def self.find_all 34 | file_pattern = File.join(model_path, '**', "*.{#{FORMATS.join(',')}}") 35 | Dir.glob(file_pattern).map do |path| 36 | relative = path.sub("#{model_path}/", '') 37 | load(relative.sub(/\.(#{FORMATS.join('|')})/, '')) 38 | end 39 | end 40 | 41 | def self.find_file_for_path(path) 42 | if ! @@filename_cache.has_key?(path) 43 | FORMATS.each do |format| 44 | [path, File.join(path, 'index')].each do |basename| 45 | filename = model_path("#{basename}.#{format}") 46 | if File.exist?(filename) 47 | @@filename_cache[path] = filename 48 | break 49 | end 50 | end 51 | end 52 | end 53 | @@filename_cache[path] 54 | end 55 | 56 | def self.needs_loading?(path, filename) 57 | @@model_cache[path].nil? || File.mtime(filename) > @@model_cache[path].mtime 58 | end 59 | 60 | def self.load(path) 61 | if (filename = find_file_for_path(path)) && needs_loading?(path, filename) 62 | @@model_cache[path] = self.new(filename) 63 | end 64 | @@model_cache[path] 65 | end 66 | 67 | def self.purge_cache 68 | @@model_cache = {} 69 | @@filename_cache = {} 70 | end 71 | 72 | def initialize(filename) 73 | @filename = filename 74 | @format = filename.split('.').last.to_sym 75 | if File.zero?(filename) 76 | @metadata = {} 77 | @markup = '' 78 | else 79 | @metadata, @markup = parse_file 80 | end 81 | @mtime = File.mtime(filename) 82 | end 83 | 84 | def ==(other) 85 | other.respond_to?(:path) && (self.path == other.path) 86 | end 87 | 88 | def index_page? 89 | @filename =~ /\/?index\.\w+$/ 90 | end 91 | 92 | def abspath 93 | file_path = @filename.sub(self.class.model_path, '') 94 | if index_page? 95 | File.dirname(file_path) 96 | else 97 | File.join(File.dirname(file_path), File.basename(file_path, '.*')) 98 | end 99 | end 100 | 101 | def path 102 | abspath.sub(/^\//, '') 103 | end 104 | 105 | def permalink 106 | File.basename(path) 107 | end 108 | 109 | def layout 110 | (metadata('layout') || 'layout').to_sym 111 | end 112 | 113 | def template 114 | (metadata('template') || 'page').to_sym 115 | end 116 | 117 | def to_html(scope = Object.new) 118 | convert_to_html(@format, scope, markup) 119 | end 120 | 121 | def last_modified 122 | @last_modified ||= File.stat(@filename).mtime 123 | end 124 | 125 | def description 126 | metadata('description') 127 | end 128 | 129 | def keywords 130 | metadata('keywords') 131 | end 132 | 133 | def metadata(key) 134 | @metadata[key] 135 | end 136 | 137 | def flagged_as?(flag) 138 | flags = metadata('flags') 139 | flags && flags.split(',').map { |name| name.strip }.include?(flag) 140 | end 141 | 142 | def parse_metadata(first_paragraph) 143 | is_metadata = first_paragraph.split("\n").first =~ /^[\w ]+:/ 144 | raise MetadataParseError unless is_metadata 145 | metadata = CaseInsensitiveHash.new 146 | first_paragraph.split("\n").each do |line| 147 | key, value = line.split(/\s*:\s*/, 2) 148 | next if value.nil? 149 | metadata[key.downcase] = value.chomp 150 | end 151 | metadata 152 | end 153 | 154 | private 155 | 156 | def markup 157 | @markup 158 | end 159 | 160 | def parse_file 161 | contents = File.open(@filename).read 162 | rescue Errno::ENOENT 163 | raise Sinatra::NotFound 164 | else 165 | first_paragraph, remaining = contents.split(/\r?\n\r?\n/, 2) 166 | begin 167 | return parse_metadata(first_paragraph), remaining 168 | rescue MetadataParseError 169 | return {}, contents 170 | end 171 | end 172 | 173 | def add_p_tags_to_haml(text) 174 | contains_tags = (text =~ /^\s*%/) 175 | if contains_tags 176 | text 177 | else 178 | text.split(/\r?\n/).inject('') do |accumulator, line| 179 | accumulator << "%p #{line}\n" 180 | end 181 | end 182 | end 183 | 184 | def convert_to_html(format, scope, text) 185 | text = add_p_tags_to_haml(text) if @format == :haml 186 | template = Tilt[format].new(renderer_config(@format)) { text } 187 | template.render(scope) 188 | end 189 | 190 | def renderer_config(format) 191 | {} 192 | end 193 | end 194 | end 195 | -------------------------------------------------------------------------------- /lib/nesta/models/menu.rb: -------------------------------------------------------------------------------- 1 | module Nesta 2 | class Menu 3 | INDENT = ' ' * 2 4 | 5 | def self.full_menu 6 | menu = [] 7 | menu_file = Nesta::Config.content_path('menu.txt') 8 | if File.exist?(menu_file) 9 | File.open(menu_file) { |file| append_menu_item(menu, file, 0) } 10 | end 11 | menu 12 | end 13 | 14 | def self.top_level 15 | full_menu.reject { |item| item.is_a?(Array) } 16 | end 17 | 18 | def self.for_path(path) 19 | path = path.sub(Regexp.new('^/'), '') 20 | if path.empty? 21 | full_menu 22 | else 23 | find_menu_item_by_path(full_menu, path) 24 | end 25 | end 26 | 27 | private_class_method def self.append_menu_item(menu, file, depth) 28 | path = file.readline 29 | rescue EOFError 30 | else 31 | page = Page.load(path.strip) 32 | current_depth = path.scan(INDENT).size 33 | if page 34 | if current_depth > depth 35 | sub_menu_for_depth(menu, depth) << [page] 36 | else 37 | sub_menu_for_depth(menu, current_depth) << page 38 | end 39 | end 40 | append_menu_item(menu, file, current_depth) 41 | end 42 | 43 | private_class_method def self.sub_menu_for_depth(menu, depth) 44 | sub_menu = menu 45 | depth.times { sub_menu = sub_menu[-1] } 46 | sub_menu 47 | end 48 | 49 | private_class_method def self.find_menu_item_by_path(menu, path) 50 | item = menu.detect do |item| 51 | item.respond_to?(:path) && (item.path == path) 52 | end 53 | if item 54 | subsequent = menu[menu.index(item) + 1] 55 | item = [item] 56 | item << subsequent if subsequent.respond_to?(:each) 57 | else 58 | sub_menus = menu.select { |menu_item| menu_item.respond_to?(:each) } 59 | sub_menus.each do |sub_menu| 60 | item = find_menu_item_by_path(sub_menu, path) 61 | break if item 62 | end 63 | end 64 | item 65 | end 66 | end 67 | end 68 | -------------------------------------------------------------------------------- /lib/nesta/models/page.rb: -------------------------------------------------------------------------------- 1 | module Nesta 2 | class HeadingNotSet < RuntimeError; end 3 | class LinkTextNotSet < RuntimeError; end 4 | 5 | class Page < FileModel 6 | def self.model_path(basename = nil) 7 | Nesta::Config.page_path(basename) 8 | end 9 | 10 | def self.find_by_path(path) 11 | page = load(path) 12 | page && page.hidden? ? nil : page 13 | end 14 | 15 | def self.find_all 16 | super.select { |p| ! p.hidden? } 17 | end 18 | 19 | def self.find_articles 20 | find_all.select do |page| 21 | page.date && page.date < DateTime.now 22 | end.sort { |x, y| y.date <=> x.date } 23 | end 24 | 25 | def draft? 26 | flagged_as?('draft') 27 | end 28 | 29 | def hidden? 30 | draft? && Nesta::App.production? 31 | end 32 | 33 | def heading 34 | regex = case @format 35 | when :mdown, :md 36 | /^#\s*(.*?)(\s*#+|$)/ 37 | when :haml 38 | /^\s*%h1\s+(.*)/ 39 | when :textile 40 | /^\s*h1\.\s+(.*)/ 41 | end 42 | markup =~ regex 43 | Regexp.last_match(1) or raise HeadingNotSet, "#{abspath} needs a heading" 44 | end 45 | 46 | def link_text 47 | metadata('link text') || heading 48 | rescue HeadingNotSet 49 | raise LinkTextNotSet, "Need to link to '#{abspath}' but can't get link text" 50 | end 51 | 52 | def title 53 | metadata('title') || link_text 54 | rescue LinkTextNotSet 55 | return Nesta::Config.title if abspath == '/' 56 | raise 57 | end 58 | 59 | def date(format = nil) 60 | @date ||= if metadata('date') 61 | if format == :xmlschema 62 | Time.parse(metadata('date')).xmlschema 63 | else 64 | DateTime.parse(metadata('date')) 65 | end 66 | end 67 | end 68 | 69 | def atom_id 70 | metadata('atom id') 71 | end 72 | 73 | def read_more 74 | metadata('read more') || Nesta::Config.read_more 75 | end 76 | 77 | def summary 78 | if summary_text = metadata('summary') 79 | summary_text.gsub!('\n', "\n") 80 | convert_to_html(@format, Object.new, summary_text) 81 | end 82 | end 83 | 84 | def body_markup 85 | case @format 86 | when :mdown, :md 87 | markup.sub(/^#[^#].*$\r?\n(\r?\n)?/, '') 88 | when :haml 89 | markup.sub(/^\s*%h1\s+.*$\r?\n(\r?\n)?/, '') 90 | when :textile 91 | markup.sub(/^\s*h1\.\s+.*$\r?\n(\r?\n)?/, '') 92 | end 93 | end 94 | 95 | def body(scope = Object.new) 96 | convert_to_html(@format, scope, body_markup) 97 | end 98 | 99 | def categories 100 | paths = category_strings.map { |specifier| specifier.sub(/:-?\d+$/, '') } 101 | valid_paths(paths).map { |p| Page.find_by_path(p) } 102 | end 103 | 104 | def priority(category) 105 | category_string = category_strings.detect do |string| 106 | string =~ /^#{category}([,:\s]|$)/ 107 | end 108 | category_string && category_string.split(':', 2)[-1].to_i 109 | end 110 | 111 | def parent 112 | if abspath == '/' 113 | nil 114 | else 115 | parent_path = File.dirname(path) 116 | while parent_path != '.' do 117 | parent = Page.load(parent_path) 118 | return parent unless parent.nil? 119 | parent_path = File.dirname(parent_path) 120 | end 121 | return categories.first unless categories.empty? 122 | Page.load('index') 123 | end 124 | end 125 | 126 | def pages 127 | in_category = Page.find_all.select do |page| 128 | page.date.nil? && page.categories.include?(self) 129 | end 130 | in_category.sort do |x, y| 131 | by_priority = y.priority(path) <=> x.priority(path) 132 | if by_priority == 0 133 | x.link_text.downcase <=> y.link_text.downcase 134 | else 135 | by_priority 136 | end 137 | end 138 | end 139 | 140 | def articles 141 | Page.find_articles.select { |article| article.categories.include?(self) } 142 | end 143 | 144 | def receives_comments? 145 | ! date.nil? 146 | end 147 | 148 | private 149 | 150 | def category_strings 151 | strings = metadata('categories') 152 | strings.nil? ? [] : strings.split(',').map { |string| string.strip } 153 | end 154 | 155 | def valid_paths(paths) 156 | page_dir = Nesta::Config.page_path 157 | paths.select do |path| 158 | FORMATS.detect do |format| 159 | [path, File.join(path, 'index')].detect do |candidate| 160 | File.exist?(File.join(page_dir, "#{candidate}.#{format}")) 161 | end 162 | end 163 | end 164 | end 165 | end 166 | 167 | end 168 | -------------------------------------------------------------------------------- /lib/nesta/navigation.rb: -------------------------------------------------------------------------------- 1 | module Nesta 2 | module Navigation 3 | module Renderers 4 | def display_menu(menu, options = {}) 5 | defaults = { class: nil, levels: 2 } 6 | options = defaults.merge(options) 7 | if options[:levels] > 0 8 | haml_tag :ul, class: options[:class] do 9 | menu.each do |item| 10 | display_menu_item(item, options) 11 | end 12 | end 13 | end 14 | end 15 | 16 | def display_menu_item(item, options = {}) 17 | if item.respond_to?(:each) 18 | if (options[:levels] - 1) > 0 19 | haml_tag :li do 20 | display_menu(item, levels: (options[:levels] - 1)) 21 | end 22 | end 23 | else 24 | html_class = current_item?(item) ? current_menu_item_class : nil 25 | haml_tag :li, class: html_class do 26 | haml_tag :a, :<, href: path_to(item.abspath) do 27 | haml_concat link_text(item) 28 | end 29 | end 30 | end 31 | end 32 | 33 | def breadcrumb_ancestors 34 | ancestors = [] 35 | page = @page 36 | while page 37 | ancestors << page 38 | page = page.parent 39 | end 40 | ancestors.reverse 41 | end 42 | 43 | def display_breadcrumbs(options = {}) 44 | haml_tag :ul, class: options[:class] do 45 | breadcrumb_ancestors[0...-1].each do |page| 46 | haml_tag :li do 47 | haml_tag :a, :<, href: path_to(page.abspath), itemprop: 'url' do 48 | haml_tag :span, :<, itemprop: 'title' do 49 | haml_concat link_text(page) 50 | end 51 | end 52 | end 53 | end 54 | haml_tag(:li, class: current_breadcrumb_class) do 55 | haml_concat link_text(@page) 56 | end 57 | end 58 | end 59 | 60 | def link_text(page) 61 | page.link_text 62 | rescue LinkTextNotSet 63 | return 'Home' if page.abspath == '/' 64 | raise 65 | end 66 | 67 | def current_item?(item) 68 | request.path_info == item.abspath 69 | end 70 | 71 | def current_menu_item_class 72 | 'current' 73 | end 74 | 75 | def current_breadcrumb_class 76 | nil 77 | end 78 | end 79 | end 80 | end 81 | -------------------------------------------------------------------------------- /lib/nesta/overrides.rb: -------------------------------------------------------------------------------- 1 | module Nesta 2 | module Overrides 3 | module Renderers 4 | def find_template(views, name, engine, &block) 5 | user_paths = [ 6 | Nesta::Overrides.local_view_path, 7 | Nesta::Overrides.theme_view_path, 8 | views 9 | ].flatten.compact 10 | user_paths.each do |path| 11 | super(path, name, engine, &block) 12 | end 13 | end 14 | 15 | def stylesheet(template, options = {}, locals = {}) 16 | scss(template, options, locals) 17 | rescue Errno::ENOENT 18 | sass(template, options, locals) 19 | end 20 | end 21 | 22 | def self.local_view_path 23 | Nesta::Path.local('views') 24 | end 25 | 26 | def self.theme_view_path 27 | if Nesta::Config.theme.nil? 28 | nil 29 | else 30 | Nesta::Path.themes(Nesta::Config.theme, 'views') 31 | end 32 | end 33 | 34 | def self.load_local_app 35 | app_file = Nesta::Path.local('app.rb') 36 | require app_file if File.exist?(app_file) 37 | end 38 | 39 | def self.load_theme_app 40 | if Nesta::Config.theme 41 | app_file = Nesta::Path.themes(Nesta::Config.theme, 'app.rb') 42 | require app_file if File.exist?(app_file) 43 | end 44 | end 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /lib/nesta/path.rb: -------------------------------------------------------------------------------- 1 | module Nesta 2 | class Path 3 | def self.local(*args) 4 | File.expand_path(File.join(args), Nesta::App.root) 5 | end 6 | 7 | def self.themes(*args) 8 | File.expand_path(File.join('themes', *args), Nesta::App.root) 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /lib/nesta/plugin.rb: -------------------------------------------------------------------------------- 1 | module Nesta 2 | module Plugin 3 | class << self 4 | attr_accessor :loaded 5 | end 6 | self.loaded ||= [] 7 | 8 | def self.register(path) 9 | name = File.basename(path, '.rb') 10 | prefix = 'nesta-plugin-' 11 | name.start_with?(prefix) || raise("Plugin names must match '#{prefix}*'") 12 | self.loaded << name 13 | end 14 | 15 | def self.initialize_plugins 16 | self.loaded.each { |name| require "#{name}/init" } 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /lib/nesta/static/assets.rb: -------------------------------------------------------------------------------- 1 | require 'rake' 2 | 3 | module Nesta 4 | module Static 5 | class Assets 6 | def initialize(build_dir, logger = nil) 7 | @build_dir = build_dir 8 | @logger = logger 9 | end 10 | 11 | def copy_attachments 12 | dest_basename = File.basename(Nesta::Config.attachment_path) 13 | dest_dir = File.join(@build_dir, dest_basename) 14 | copy_file_tree(Nesta::Config.attachment_path, dest_dir) 15 | end 16 | 17 | def copy_public_folder 18 | copy_file_tree(Nesta::App.settings.public_folder, @build_dir) 19 | end 20 | 21 | private 22 | 23 | def log(message) 24 | @logger.call(message) if @logger 25 | end 26 | 27 | def copy_file_tree(source_dir, dest_dir) 28 | files_in_tree(source_dir).each do |file| 29 | target = File.join(dest_dir, file.sub(/^#{source_dir}\//, '')) 30 | task = Rake::FileTask.define_task(target => file) do 31 | target_dir = File.dirname(target) 32 | FileUtils.mkdir_p(target_dir) unless Dir.exist?(target_dir) 33 | FileUtils.cp(file, target_dir) 34 | log("Copied #{file} to #{target}") 35 | end 36 | task.invoke 37 | end 38 | end 39 | 40 | def files_in_tree(directory) 41 | Rake::FileList["#{directory}/**/*"].tap do |assets| 42 | assets.exclude('~*') 43 | assets.exclude do |f| 44 | File.directory?(f) 45 | end 46 | end 47 | end 48 | end 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /lib/nesta/static/html_file.rb: -------------------------------------------------------------------------------- 1 | module Nesta 2 | module Static 3 | class HtmlFile 4 | def initialize(build_dir, page) 5 | @build_dir = build_dir 6 | @content_path = page.filename 7 | end 8 | 9 | def page_shares_path_with_directory?(dir, base_without_ext) 10 | Dir.exist?(File.join(dir, base_without_ext)) 11 | end 12 | 13 | def filename 14 | dir, base = File.split(@content_path) 15 | base_without_ext = File.basename(base, File.extname(base)) 16 | subdir = dir.sub(/^#{Nesta::Config.page_path}/, '') 17 | path = File.join(@build_dir, subdir, base_without_ext) 18 | if page_shares_path_with_directory?(dir, base_without_ext) 19 | File.join(path, 'index.html') 20 | else 21 | path + '.html' 22 | end 23 | end 24 | end 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /lib/nesta/static/site.rb: -------------------------------------------------------------------------------- 1 | require 'rake' 2 | 3 | require_relative './html_file' 4 | 5 | module Nesta 6 | module Static 7 | class Site 8 | def initialize(build_dir, domain, logger = nil) 9 | @build_dir = build_dir 10 | @domain = domain 11 | @logger = logger 12 | @app = Nesta::App.new 13 | set_app_root 14 | end 15 | 16 | def render_pages 17 | Nesta::Page.find_all.each do |page| 18 | target = HtmlFile.new(@build_dir, page).filename 19 | source = page.filename 20 | task = Rake::FileTask.define_task(target => source) do 21 | save_markup(target, render(page.abspath, target, source)) 22 | end 23 | task.invoke 24 | end 25 | end 26 | 27 | def render_not_found 28 | path_info = '/404' 29 | source = 'no-such-file.md' 30 | target = File.join(@build_dir, '404.html') 31 | markup = render(path_info, target, source, expected_code: 404) 32 | save_markup(target, markup) 33 | end 34 | 35 | def render_atom_feed 36 | filename = 'articles.xml' 37 | path_info = "/#{filename}" 38 | description = 'Atom feed' 39 | target = File.join(@build_dir, filename) 40 | markup = render(path_info, target, description) 41 | save_markup(target, markup) 42 | end 43 | 44 | def render_sitemap 45 | filename = File.join(@build_dir, 'sitemap.xml') 46 | save_markup(filename, render('/sitemap.xml', filename, 'site')) 47 | end 48 | 49 | def render_templated_assets 50 | Nesta::Config.build.fetch('templated_assets', []).each do |path| 51 | filename = File.join(@build_dir, path) 52 | save_markup(filename, render(path, filename, path)) 53 | end 54 | end 55 | 56 | private 57 | 58 | def log(message) 59 | @logger.call(message) if @logger 60 | end 61 | 62 | def set_app_root 63 | root = ::File.expand_path('.') 64 | ['Gemfile', File.join('config', 'config.yml')].each do |expected| 65 | if ! File.exist?(File.join(root, expected)) 66 | message = "is this a Nesta site? (expected './#{expected}')" 67 | raise RuntimeError, message 68 | end 69 | end 70 | Nesta::App.root = root 71 | end 72 | 73 | def rack_environment(abspath) 74 | { 75 | 'REQUEST_METHOD' => 'GET', 76 | 'SCRIPT_NAME' => '', 77 | 'PATH_INFO' => abspath, 78 | 'QUERY_STRING' => '', 79 | 'SERVER_NAME' => @domain, 80 | 'SERVER_PROTOCOL' => 'https', 81 | 'rack.url_scheme' => 'https', 82 | 'rack.input' => StringIO.new, 83 | 'rack.errors' => STDERR 84 | } 85 | end 86 | 87 | def render(abspath, filename, description, expected_code: 200) 88 | http_code, headers, body = @app.call(rack_environment(abspath)) 89 | if http_code != expected_code 90 | raise RuntimeError, "Can't render #{filename} from #{description}" 91 | end 92 | body.join 93 | end 94 | 95 | def save_markup(filename, content) 96 | FileUtils.mkdir_p(File.dirname(filename)) 97 | if ! File.exist?(filename) || File.open(filename, 'r').read != content 98 | File.open(filename, 'w') { |output| output.write(content) } 99 | log("Rendered #{filename}") 100 | end 101 | end 102 | end 103 | end 104 | end 105 | -------------------------------------------------------------------------------- /lib/nesta/system_command.rb: -------------------------------------------------------------------------------- 1 | module Nesta 2 | class SystemCommand 3 | def run(*args) 4 | system(*args) 5 | if ! $?.success? 6 | message = if $?.exitstatus == 127 7 | "#{args[0]} not found" 8 | else 9 | "'#{args.join(' ')}' failed with status #{$?.exitstatus}" 10 | end 11 | Nesta.fail_with(message) 12 | end 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /lib/nesta/version.rb: -------------------------------------------------------------------------------- 1 | module Nesta 2 | VERSION = '0.18.0' 3 | end 4 | -------------------------------------------------------------------------------- /nesta.gemspec: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | $:.push File.expand_path('../lib', __FILE__) 3 | require 'nesta/version' 4 | 5 | Gem::Specification.new do |s| 6 | s.name = 'nesta' 7 | s.version = Nesta::VERSION 8 | s.platform = Gem::Platform::RUBY 9 | s.authors = ['Graham Ashton'] 10 | s.email = ['graham@effectif.com'] 11 | s.homepage = 'https://nestacms.com' 12 | s.summary = %q{Ruby CMS, written in Sinatra} 13 | s.description = <<-EOF 14 | Nesta is a lightweight Content Management System, written in Ruby using 15 | the Sinatra web framework. Nesta has the simplicity of a static site 16 | generator, but (being a fully fledged Rack application) allows you to 17 | serve dynamic content on demand. 18 | 19 | Content is stored on disk in plain text files (there is no database). 20 | Edit your content in a text editor and keep it under version control 21 | (most people use git, but any version control system will do fine). 22 | 23 | Implementing your site's design is easy, but Nesta also has a small 24 | selection of themes to choose from. 25 | EOF 26 | 27 | s.files = `git ls-files`.split("\n") 28 | s.test_files = `git ls-files -- {test,spec,features}/*`.split("\n") 29 | s.executables = `git ls-files -- bin/*`.split("\n").map{ |f| File.basename(f) } 30 | s.require_paths = ['lib'] 31 | 32 | s.add_dependency('haml', '>= 3.1', '< 6.0') 33 | s.add_dependency('haml-contrib', '>= 1.0') 34 | s.add_dependency('rack', '~> 3') 35 | s.add_dependency('rake') 36 | s.add_dependency('rdiscount', '~> 2.1') 37 | s.add_dependency('RedCloth', '~> 4.2') 38 | s.add_dependency('sass-embedded', '~> 1.58') 39 | s.add_dependency('sinatra', '~> 4.0') 40 | s.add_dependency('tilt', '~> 2.1') 41 | 42 | # Useful in development 43 | s.add_development_dependency('mr-sparkle') 44 | 45 | # Test libraries 46 | s.add_development_dependency('byebug') 47 | s.add_development_dependency('capybara', '~> 2.0') 48 | s.add_development_dependency('minitest', '~> 5.0') 49 | s.add_development_dependency('minitest-reporters') 50 | end 51 | -------------------------------------------------------------------------------- /scripts/import-from-mephisto: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | # Assumptions: 4 | # 5 | # - It's okay to ignore mephisto's sections and sites. 6 | # - Each mephisto tag should be converted to a Nesta category. 7 | 8 | require 'getoptlong' 9 | require 'time' 10 | 11 | require 'rubygems' 12 | require 'active_record' 13 | 14 | require File.join(File.dirname(__FILE__), *%w[.. lib config]) 15 | require File.join(File.dirname(__FILE__), *%w[.. lib models]) 16 | 17 | class Content < ActiveRecord::Base 18 | has_many :taggings, as: :taggable 19 | has_many :tags, through: :taggings 20 | 21 | def self.inheritance_column 22 | @inheritance_column = 'none' 23 | end 24 | 25 | def self.articles 26 | conditions = "type = 'Article' and published_at is not NULL" 27 | find(:all, conditions: conditions).map { |c| ArticleLoader.new(c) } 28 | end 29 | end 30 | 31 | class Tagging < ActiveRecord::Base 32 | belongs_to :content 33 | belongs_to :tag 34 | end 35 | 36 | class Tag < ActiveRecord::Base 37 | has_many :taggings 38 | has_many :contents, through: :taggings 39 | 40 | def filename 41 | File.join(Nesta::Config.page_path, "#{name}.mdown") 42 | end 43 | end 44 | 45 | module ContentWrapper 46 | def initialize(content) 47 | @content = content 48 | end 49 | 50 | def permalink 51 | @content.permalink 52 | end 53 | 54 | private 55 | 56 | def metadata_string(metadata) 57 | metadata.map { |key, value| "#{key}: #{value}" }.sort.join("\n") 58 | end 59 | end 60 | 61 | class ArticleLoader 62 | include ContentWrapper 63 | 64 | def filename 65 | File.join(Nesta::Config.page_path, "#{permalink}.mdown") 66 | end 67 | 68 | def date 69 | @content.published_at 70 | end 71 | 72 | def categories 73 | @content.tags.map { |t| t.name }.sort.join(', ') 74 | end 75 | 76 | def summary 77 | @content.excerpt.strip.gsub(/\r?\n/, '\n') 78 | end 79 | 80 | def atom_id(domain) 81 | published = "#{@content.created_at.strftime('%Y-%m-%d')}" 82 | "tag:#{domain},#{published}:#{@content.id}" 83 | end 84 | 85 | def metadata(domain) 86 | metadata = { 87 | 'Date' => date, 88 | 'Categories' => categories, 89 | 'Summary' => summary 90 | } 91 | metadata.merge!('Atom ID' => atom_id(domain)) if domain 92 | metadata_string(metadata) 93 | end 94 | 95 | def content 96 | ["# #{@content.title}", @content.body.gsub("\r\n", "\n")].join("\n\n") 97 | end 98 | end 99 | 100 | class App 101 | def usage 102 | script = File.basename($PROGRAM_NAME) 103 | $stderr.write <<-EOF 104 | Usage: #{script} [OPTIONS] -u -p 105 | 106 | OPTIONS (defaults shown in brackets) 107 | 108 | -a, --adapter Database adapter (mysql) 109 | --domain Site's domain name (for preserving tags in feed) 110 | --clobber Overwrite existing files 111 | -d, --database Database name (mephisto_production) 112 | -h, --host Database hostname (localhost) 113 | -p, --password Database password 114 | -u, --username Database username 115 | 116 | EOF 117 | exit 1 118 | end 119 | 120 | def parse_command_line 121 | parser = GetoptLong.new 122 | parser.set_options( 123 | ['-a', '--adapter', GetoptLong::REQUIRED_ARGUMENT], 124 | ['--domain', GetoptLong::REQUIRED_ARGUMENT], 125 | ['--clobber', GetoptLong::NO_ARGUMENT], 126 | ['-d', '--database', GetoptLong::REQUIRED_ARGUMENT], 127 | ['-h', '--host', GetoptLong::REQUIRED_ARGUMENT], 128 | ['-u', '--username', GetoptLong::REQUIRED_ARGUMENT], 129 | ['-p', '--password', GetoptLong::REQUIRED_ARGUMENT] 130 | ) 131 | loop do 132 | opt, arg = parser.get 133 | break if not opt 134 | case opt 135 | when '-a' 136 | @adapter = arg 137 | when '--domain' 138 | @domain = arg 139 | when '--clobber' 140 | @clobber = true 141 | when '-d' 142 | @database = arg 143 | when '-h' 144 | @host = arg 145 | when '-p' 146 | @password = arg 147 | when '-u' 148 | @username = arg 149 | end 150 | end 151 | @adapter ||= 'mysql' 152 | @clobber.nil? && @clobber = false 153 | @host ||= 'localhost' 154 | @database ||= 'mephisto_production' 155 | usage if @username.nil? || @password.nil? 156 | end 157 | 158 | def connect_to_database 159 | ActiveRecord::Base.establish_connection( 160 | adapter: @adapter, 161 | host: @host, 162 | username: @username, 163 | password: @password, 164 | database: @database 165 | ) 166 | end 167 | 168 | def should_import?(item) 169 | if File.exist?(item.filename) && (! @clobber) 170 | puts 'skipping (specify --clobber to overwrite)' 171 | false 172 | else 173 | true 174 | end 175 | end 176 | 177 | def import_articles 178 | Content.articles.each do |article| 179 | puts "Importing article: #{article.permalink}" 180 | if should_import?(article) 181 | File.open(article.filename, 'w') do |file| 182 | file.write [article.metadata(@domain), article.content].join("\n\n") 183 | end 184 | end 185 | end 186 | end 187 | 188 | def import_tags 189 | Tag.find(:all).each do |tag| 190 | puts "Importing tag: #{tag.name}" 191 | if should_import?(tag) 192 | File.open(tag.filename, 'w') do |file| 193 | file.write("# #{tag.name.capitalize}\n") 194 | end 195 | end 196 | end 197 | end 198 | 199 | def main 200 | parse_command_line 201 | connect_to_database 202 | import_articles 203 | import_tags 204 | rescue GetoptLong::Error 205 | exit 1 206 | end 207 | end 208 | 209 | app = App.new 210 | app.main 211 | -------------------------------------------------------------------------------- /templates/Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | gem 'nesta' 4 | <% if @options['vlad'] %>gem 'vlad', '2.1.0' 5 | gem 'vlad-git', '2.2.0'<% end %> 6 | 7 | group :development do 8 | gem 'mr-sparkle' 9 | end 10 | -------------------------------------------------------------------------------- /templates/Rakefile: -------------------------------------------------------------------------------- 1 | require 'rubygems' 2 | require 'bundler/setup' 3 | 4 | Bundler.require(:default, :test) 5 | <% if @options['vlad'] %> 6 | begin 7 | require 'vlad' 8 | # Set :app to :passenger if you're using Phusion Passenger. 9 | Vlad.load(:scm => :git, :app => nil, :web => nil) 10 | rescue LoadError 11 | end<% end %> 12 | -------------------------------------------------------------------------------- /templates/config.ru: -------------------------------------------------------------------------------- 1 | require 'rubygems' 2 | require 'bundler/setup' 3 | 4 | Bundler.require(:default) 5 | 6 | use Rack::ConditionalGet 7 | use Rack::ETag 8 | 9 | require 'nesta/env' 10 | Nesta::Env.root = ::File.expand_path('.', ::File.dirname(__FILE__)) 11 | 12 | require 'nesta/app' 13 | run Nesta::App 14 | -------------------------------------------------------------------------------- /templates/config/config.yml: -------------------------------------------------------------------------------- 1 | # Title and subheading for your site. Used on the home page and in page 2 | # titles. 3 | # 4 | title: "My Site" 5 | subtitle: "(change this text in config/config.yml)" 6 | 7 | # You should really specify your content's author when generating an 8 | # Atom feed. Specify at least one of name, uri or email, and Nesta will 9 | # include it in your feed. See the Atom spec for more info: 10 | # 11 | # https://tools.ietf.org/html/rfc4287#section-4.1.1 12 | # 13 | # author: 14 | # name: Your Name 15 | # uri: https://yourdomain.com 16 | # email: you@yourdomain.com 17 | 18 | # You can stick with the default look and feel, or use a theme. Themes are 19 | # easy to create or install, and live inside the themes directory. You 20 | # can also use scripts/theme to install them. 21 | # 22 | # theme: name-of-theme 23 | 24 | # If you want to use the Disqus service (https://disqus.com) to display 25 | # comments on your site, register a Disqus account and then specify your 26 | # site's short name here. A comment form will automatically be added to 27 | # the bottom of your pages. 28 | # 29 | # disqus_short_name: mysite 30 | 31 | # content 32 | # The root directory where nesta will look for your article files. 33 | # Should contain "pages" and "attachments" subdirectories that contain 34 | # your actual content and the (optional) menu.txt file that links to your 35 | # main category pages. 36 | # 37 | content: content 38 | 39 | # google_analytics_code 40 | # Set this if you want Google Analytics to track traffic on your site. 41 | # Probably best not to set a default value, but to set it in production. 42 | # 43 | # The production settings are used if you're deploying to Heroku, so 44 | # scroll down a bit to set it in production even if you're not deploying 45 | # to your own server. 46 | # 47 | # google_analytics_code: "G-??????????" 48 | 49 | # read_more 50 | # When the summary of an article is displayed on the home page, or 51 | # on a category page, there is a link underneath the summary that 52 | # points to the rest of the post. Use this value to customize the 53 | # default link text. You can still override this value on a per-page 54 | # basis with the 'Read more' metadata key. 55 | # 56 | # read_more: "Continue reading" 57 | 58 | # Overriding "content" in production is recommended if you're deploying 59 | # Nesta to your own server (but see the deployment documentation on the 60 | # Nesta site). Setting google_analytics_code in production is recommended 61 | # regardless of how you're deploying (if you have a GA # account!). 62 | # 63 | # Don't forget to uncomment the "production:" line too... 64 | 65 | # production: 66 | # content: /var/apps/nesta/shared/content 67 | # google_analytics_code: "G-??????????" 68 | 69 | # The following settings control the behaviour of the `nesta build` 70 | # command, which is used to generate a "static" version of your site. 71 | # It's optional, but allows you to deploy your site on hosting providers 72 | # that serve pre-prepared HTML and CSS files (instead of generating them 73 | # in real-time with a web server). 74 | # 75 | # You can either set the `domain` key here, or specify it on the command 76 | # line with the `--domain` switch. Nesta needs to know your site's 77 | # domain name so that it can generate the correct URLs in sitemap.xml, 78 | # and in your Atom feed. 79 | # 80 | # The templated_assets setting is a list of the things that Nesta 81 | # generates for you, other from all the page in your content directory. 82 | # The example below is for a site that has two stylesheets, both of 83 | # which are converted from .sass or .scss files in your ./views folder. 84 | # 85 | # If you're not using Sass and are just editing CSS files directly, you 86 | # can keep them in your ./public folder and Nesta's build command will 87 | # find them automatically. 88 | # 89 | # build: 90 | # domain: yourdomain.com 91 | # templated_assets: 92 | # - /css/master.css 93 | # - /css/local.css 94 | -------------------------------------------------------------------------------- /templates/config/deploy.rb: -------------------------------------------------------------------------------- 1 | set :application, '<%= File.basename(@path) %>' 2 | set :repository, "set this to URL of your site's repo" 3 | 4 | # Set :user if you want to connect (via ssh) to your server using a 5 | # different username. You will also need to include the user in :domain 6 | # (see below). 7 | # 8 | #set :user, "deploy" 9 | #set :domain, "#{user}@example.com" 10 | set :domain, 'example.com' 11 | 12 | set :deploy_to, "/var/apps/#{application}" 13 | 14 | # ============================================================================ 15 | # You probably don't need to worry about anything beneath this point... 16 | # ============================================================================ 17 | 18 | require 'tempfile' 19 | require 'vlad' 20 | 21 | namespace :vlad do 22 | remote_task :symlink_attachments do 23 | run "ln -s #{shared_path}/content/attachments #{current_path}/public/attachments" 24 | end 25 | 26 | task :update do 27 | Rake::Task['vlad:symlink_attachments'].invoke 28 | end 29 | 30 | remote_task :bundle do 31 | run "cd #{current_path} && sudo bundle install --without development test" 32 | end 33 | 34 | # Depending on how you host Nesta, you might want to swap :start_app 35 | # with :start below. The :start_app task will tell your application 36 | # server (e.g. Passenger) to restart once your new code is deployed by 37 | # :update. Passenger is the default app server; tell Vlad that you're 38 | # using a different app server in the call to Vlad.load in Rakefile. 39 | # 40 | desc 'Deploy the code and restart the server' 41 | task deploy: [:update, :start_app] 42 | 43 | # If you use bundler to manage the installation of gems on your server 44 | # you can use this definition of the deploy task instead: 45 | # 46 | # task deploy: [:update, :bundle, :start_app] 47 | end 48 | -------------------------------------------------------------------------------- /templates/index.haml: -------------------------------------------------------------------------------- 1 | Link text: Home 2 | 3 | %section.articles= article_summaries(latest_articles) 4 | -------------------------------------------------------------------------------- /templates/plugins/Gemfile: -------------------------------------------------------------------------------- 1 | source 'http://rubygems.org' 2 | 3 | # Specify your gem's dependencies in <%= @gem_name %>.gemspec 4 | gemspec 5 | -------------------------------------------------------------------------------- /templates/plugins/README.md: -------------------------------------------------------------------------------- 1 | README 2 | ====== 3 | 4 | TODO: Explain what your plugin is for 5 | 6 | Installation 7 | ------------ 8 | 9 | To use this plugin just add it to your Nesta project's `Gemfile` and 10 | then install it with Bundler: 11 | 12 | $ echo 'gem "<%= @gem_name %>"' >> Gemfile 13 | $ bundle 14 | -------------------------------------------------------------------------------- /templates/plugins/Rakefile: -------------------------------------------------------------------------------- 1 | def version 2 | version_file = File.join(File.dirname(__FILE__), 'lib', name, 'version.rb') 3 | contents = File.read(version_file) 4 | contents.match(/VERSION = ['"]([0-9a-z.-]+)['"].*$/) 5 | $1 6 | end 7 | 8 | def name 9 | '<%= @gem_name %>' 10 | end 11 | 12 | def built_gem_path 13 | gem_packages = File.join(File.dirname(__FILE__), 'pkg', "#{name}-*.gem") 14 | Dir[gem_packages].sort_by { |file| File.mtime(file) }.last 15 | end 16 | 17 | def already_tagged? 18 | `git tag`.split(/\n/).include?("v#{version}") 19 | end 20 | 21 | desc "Build #{name}-#{version}.gem into the pkg directory." 22 | task 'build' do 23 | `gem build -V #{File.join(File.dirname(__FILE__), "#{name}.gemspec")}` 24 | FileUtils.mkdir_p(File.join(File.dirname(__FILE__), 'pkg')) 25 | gem = Dir[File.join(File.dirname(__FILE__), "#{name}-*.gem")].sort_by{|f| File.mtime(f)}.last 26 | FileUtils.mv(gem, 'pkg') 27 | puts "#{name} #{version} built to #{built_gem_path}." 28 | end 29 | 30 | desc "Build and install #{name}-#{version}.gem into system gems." 31 | task 'install' => 'build' do 32 | `gem install '#{built_gem_path}' --local` 33 | end 34 | 35 | desc "Create tag v#{version} and build and push #{name}-#{version}.gem to Rubygems\n" \ 36 | 'To prevent publishing in Rubygems use `gem_push=no rake release`' 37 | task 'release' => ['build', 'release:guard_clean', 38 | 'release:source_control_push', 'release:rubygem_push'] do 39 | end 40 | 41 | task 'release:guard_clean' do 42 | if !system('git diff --exit-code') || !system('git diff-index --quiet --cached HEAD') 43 | puts 'There are files that need to be committed first.' 44 | exit(1) 45 | end 46 | end 47 | 48 | task 'release:source_control_push' do 49 | unless already_tagged? 50 | system "git tag -a -m 'Version #{version}' v#{version}" 51 | system 'git push' 52 | system 'git push --tags' 53 | end 54 | end 55 | 56 | task 'release:rubygem_push' do 57 | system "gem push #{built_gem_path}" 58 | end 59 | -------------------------------------------------------------------------------- /templates/plugins/gitignore: -------------------------------------------------------------------------------- 1 | *.gem 2 | Gemfile.lock 3 | pkg/* 4 | -------------------------------------------------------------------------------- /templates/plugins/lib/init.rb: -------------------------------------------------------------------------------- 1 | module Nesta 2 | module Plugin 3 | module <%= module_name %> 4 | module Helpers 5 | # If your plugin needs any helper methods, add them here... 6 | end 7 | end 8 | end 9 | 10 | class App 11 | helpers Nesta::Plugin::<%= module_name %>::Helpers 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /templates/plugins/lib/required.rb: -------------------------------------------------------------------------------- 1 | require '<%= @gem_name %>/version' 2 | 3 | Nesta::Plugin.register(__FILE__) 4 | -------------------------------------------------------------------------------- /templates/plugins/lib/version.rb: -------------------------------------------------------------------------------- 1 | module Nesta 2 | module Plugin 3 | <%= nested_module_definition_with_version %> 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /templates/plugins/plugin.gemspec: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | lib = File.expand_path('../lib', __FILE__) 3 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 4 | require "<%= @gem_name %>/version" 5 | 6 | Gem::Specification.new do |spec| 7 | spec.name = "<%= @gem_name %>" 8 | spec.version = Nesta::Plugin::<%= module_name %>::VERSION 9 | spec.authors = ["TODO: Your name"] 10 | spec.email = ["TODO: Your email address"] 11 | spec.homepage = "" 12 | spec.summary = %q{TODO: Write a gem summary} 13 | spec.description = %q{TODO: Write a gem description} 14 | spec.license = "MIT" 15 | 16 | spec.rubyforge_project = "<%= @gem_name %>" 17 | 18 | spec.files = `git ls-files -z`.split("\x0") 19 | spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) } 20 | spec.test_files = spec.files.grep(%r{^(test|spec|features)/}) 21 | spec.require_paths = ["lib"] 22 | 23 | # specify any dependencies here; for example: 24 | # spec.add_development_dependency "rspec" 25 | # spec.add_runtime_dependency "rest-client" 26 | spec.add_dependency("nesta", ">= 0.9.11") 27 | spec.add_development_dependency("rake") 28 | end 29 | -------------------------------------------------------------------------------- /templates/themes/README.md: -------------------------------------------------------------------------------- 1 | <%= @name.capitalize.gsub(/[_-]/, ' ') %> Nesta theme 2 | <%= '=' * @name.length %>============ 3 | 4 | <%= @name %> is a theme for Nesta, a [Ruby CMS](nesta), designed by 5 | . 6 | 7 | [nesta]: https://nestacms.com 8 | -------------------------------------------------------------------------------- /templates/themes/app.rb: -------------------------------------------------------------------------------- 1 | # Use the app.rb file to load Ruby code, modify or extend the models, or 2 | # do whatever else you fancy when the theme is loaded. 3 | 4 | module Nesta 5 | class App 6 | # Uncomment the Rack::Static line below if your theme has assets 7 | # (i.e images or JavaScript). 8 | # 9 | # Put your assets in themes/<%= @name %>/public/<%= @name %>. 10 | # 11 | # use Rack::Static, urls: ["/<%= @name %>"], root: "themes/<%= @name %>/public" 12 | 13 | helpers do 14 | # Add new helpers here. 15 | end 16 | 17 | # Add new routes here. 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /templates/themes/views/layout.haml: -------------------------------------------------------------------------------- 1 | 2 | %html(lang="en") 3 | %head 4 | %meta(charset="utf-8") 5 | %title 6 | %body 7 | = yield 8 | -------------------------------------------------------------------------------- /templates/themes/views/master.sass: -------------------------------------------------------------------------------- 1 | // Master Sass stylesheet 2 | // https://sass-lang.com/ 3 | 4 | -------------------------------------------------------------------------------- /templates/themes/views/page.haml: -------------------------------------------------------------------------------- 1 | ~ @page.to_html(self) 2 | -------------------------------------------------------------------------------- /test/fixtures/nesta-plugin-test/Gemfile: -------------------------------------------------------------------------------- 1 | source 'http://rubygems.org' 2 | 3 | # Specify your gem's dependencies in nesta-plugin-test.gemspec 4 | gemspec 5 | -------------------------------------------------------------------------------- /test/fixtures/nesta-plugin-test/Rakefile: -------------------------------------------------------------------------------- 1 | require 'bundler/gem_tasks' 2 | -------------------------------------------------------------------------------- /test/fixtures/nesta-plugin-test/lib/nesta-plugin-test.rb: -------------------------------------------------------------------------------- 1 | require 'nesta-plugin-test/version' 2 | 3 | Nesta::Plugin.register(__FILE__) 4 | -------------------------------------------------------------------------------- /test/fixtures/nesta-plugin-test/lib/nesta-plugin-test/init.rb: -------------------------------------------------------------------------------- 1 | module Nesta 2 | module Plugin 3 | module Test 4 | module Helpers 5 | # If your plugin needs any helper methods, add them here... 6 | end 7 | end 8 | end 9 | 10 | class App 11 | helpers Nesta::Plugin::Test::Helpers 12 | end 13 | 14 | class Page 15 | def self.method_added_by_plugin 16 | end 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /test/fixtures/nesta-plugin-test/lib/nesta-plugin-test/version.rb: -------------------------------------------------------------------------------- 1 | module Nesta 2 | module Plugin 3 | module Test 4 | VERSION = '0.0.1' 5 | end 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /test/fixtures/nesta-plugin-test/nesta-plugin-test.gemspec: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | $:.push File.expand_path('../lib', __FILE__) 3 | require 'nesta-plugin-test/version' 4 | 5 | Gem::Specification.new do |s| 6 | s.name = 'nesta-plugin-test' 7 | s.version = Nesta::Plugin::Test::VERSION 8 | s.authors = ['Graham Ashton'] 9 | s.email = ['graham@effectif.com'] 10 | s.homepage = '' 11 | s.summary = %q{TODO: Write a gem summary} 12 | s.description = %q{TODO: Write a gem description} 13 | 14 | s.rubyforge_project = 'nesta-plugin-test' 15 | 16 | s.files = `git ls-files`.split("\n") 17 | s.test_files = `git ls-files -- {test,spec,features}/*`.split("\n") 18 | s.executables = `git ls-files -- bin/*`.split("\n").map{ |f| File.basename(f) } 19 | s.require_paths = ['lib'] 20 | 21 | # specify any dependencies here; for example: 22 | # s.add_development_dependency "rspec" 23 | # s.add_runtime_dependency "rest-client" 24 | end 25 | -------------------------------------------------------------------------------- /test/integration/atom_feed_test.rb: -------------------------------------------------------------------------------- 1 | require 'integration_test_helper' 2 | 3 | describe 'Atom feed' do 4 | include Nesta::IntegrationTest 5 | 6 | def visit_feed 7 | visit '/articles.xml' 8 | end 9 | 10 | describe 'site' do 11 | it 'renders successfully' do 12 | with_temp_content_directory do 13 | visit_feed 14 | assert_equal 200, page.status_code 15 | end 16 | end 17 | 18 | it "uses Atom's XML namespace" do 19 | with_temp_content_directory do 20 | visit_feed 21 | assert_has_xpath '//feed[@xmlns="https://www.w3.org/2005/Atom"]' 22 | end 23 | end 24 | 25 | it 'has an ID element' do 26 | with_temp_content_directory do 27 | visit_feed 28 | assert page.has_selector?('id:contains("tag:www.example.com,2009:/")') 29 | end 30 | end 31 | 32 | it 'has an alternate link element' do 33 | with_temp_content_directory do 34 | visit_feed 35 | assert_has_xpath( 36 | '//feed/link[@rel="alternate"][@href="http://www.example.com/"]' 37 | ) 38 | end 39 | end 40 | 41 | it 'has a self link element' do 42 | with_temp_content_directory do 43 | visit_feed 44 | assert_has_xpath( 45 | '//feed/link[@rel="self"][@href="http://www.example.com/articles.xml"]' 46 | ) 47 | end 48 | end 49 | 50 | it 'has title and subtitle' do 51 | site_config = { 52 | 'title' => 'My blog', 53 | 'subtitle' => 'about stuff', 54 | } 55 | stub_config(temp_content.merge(site_config)) do 56 | visit_feed 57 | assert_has_xpath '//feed/title[@type="text"]', text: 'My blog' 58 | assert_has_xpath '//feed/subtitle[@type="text"]', text: 'about stuff' 59 | end 60 | end 61 | 62 | it 'includes the author details' do 63 | author_config = temp_content.merge('author' => { 64 | 'name' => 'Fred Bloggs', 65 | 'uri' => 'http://fredbloggs.com', 66 | 'email' => 'fred@fredbloggs.com' 67 | }) 68 | stub_config(temp_content.merge(author_config)) do 69 | visit_feed 70 | assert_has_xpath '//feed/author/name', text: 'Fred Bloggs' 71 | assert_has_xpath '//feed/author/uri', text: 'http://fredbloggs.com' 72 | assert_has_xpath '//feed/author/email', text: 'fred@fredbloggs.com' 73 | end 74 | end 75 | end 76 | 77 | describe 'site with articles' do 78 | it 'only lists latest 10' do 79 | with_temp_content_directory do 80 | 11.times { create(:article) } 81 | visit_feed 82 | end 83 | assert page.has_selector?('entry', count: 10), 'expected 10 articles' 84 | end 85 | end 86 | 87 | def with_category(options = {}) 88 | with_temp_content_directory do 89 | model = create(:category, options) 90 | visit_feed 91 | yield(model) 92 | end 93 | end 94 | 95 | def with_article(options = {}) 96 | with_temp_content_directory do 97 | article = create(:article, options) 98 | visit_feed 99 | yield(article) 100 | end 101 | end 102 | 103 | def with_article_in_category(options = {}) 104 | with_temp_content_directory do 105 | category = create(:category, options) 106 | article_options = options.merge(metadata: { 107 | 'categories' => category.path 108 | }) 109 | create(:article, article_options) 110 | visit_feed 111 | yield(category) 112 | end 113 | end 114 | 115 | describe 'article' do 116 | it 'sets the title' do 117 | with_article do |article| 118 | assert_has_xpath '//entry/title', text: article.heading 119 | end 120 | end 121 | 122 | it 'links to the HTML version' do 123 | with_article do |article| 124 | url = "http://www.example.com/#{article.path}" 125 | assert_has_xpath( 126 | "//entry/link[@href='#{url}'][@rel='alternate'][@type='text/html']" 127 | ) 128 | end 129 | end 130 | 131 | it 'defines unique ID' do 132 | with_article do |article| 133 | assert_has_xpath( 134 | '//entry/id', text: "tag:www.example.com,2008-12-29:#{article.abspath}" 135 | ) 136 | end 137 | end 138 | 139 | it 'uses pre-defined ID if specified' do 140 | with_article(metadata: { 'atom id' => 'use-this-id' }) do 141 | assert_has_xpath '//entry/id', text: 'use-this-id' 142 | end 143 | end 144 | 145 | it 'specifies date published' do 146 | with_article do 147 | assert_has_xpath '//entry/published', text: '2008-12-29T00:00:00+00:00' 148 | end 149 | end 150 | 151 | it 'specifies article categories' do 152 | with_article_in_category do |category| 153 | assert_has_xpath "//category[@term='#{category.permalink}']" 154 | end 155 | end 156 | 157 | it 'has article content' do 158 | with_article do 159 | assert_has_xpath '//entry/content[@type="html"]', text: 'Content' 160 | end 161 | end 162 | 163 | it 'includes hostname in URLs' do 164 | with_article(content: '[a link](/foo)') do 165 | url = 'http://www.example.com/foo' 166 | assert_has_xpath '//entry/content', text: url 167 | end 168 | end 169 | 170 | it 'does not include article heading in content' do 171 | with_article do |article| 172 | assert page.has_no_selector?("summary:contains('#{article.heading}')") 173 | end 174 | end 175 | 176 | it 'does not include pages with no date in feed' do 177 | with_category(path: 'no-date') do 178 | assert page.has_no_selector?('entry id:contains("no-date")') 179 | end 180 | end 181 | end 182 | end 183 | -------------------------------------------------------------------------------- /test/integration/commands/build_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | require_relative '../../../lib/nesta/commands' 3 | 4 | describe 'nesta build' do 5 | include ModelFactory 6 | include TestConfiguration 7 | 8 | def silencing_stdout(&block) 9 | stdout, $stdout = $stdout, StringIO.new 10 | block.call 11 | ensure 12 | $stdout.close 13 | $stdout = stdout 14 | end 15 | 16 | it 'builds HTML file from Markdown file' do 17 | in_temporary_project do 18 | with_temp_content_directory do 19 | page = create(:page) 20 | command = Nesta::Commands::Build.new('output_dir') 21 | 22 | process = Minitest::Mock.new 23 | silencing_stdout { command.execute(process) } 24 | 25 | assert_exists_in_project File.join('output_dir', page.abspath + '.html') 26 | end 27 | end 28 | end 29 | 30 | it 'reads domain name from config file' do 31 | domain = 'mysite.com' 32 | 33 | in_temporary_project do 34 | stub_config('build' => { 'domain' => domain }) do 35 | command = Nesta::Commands::Build.new('output_dir') 36 | 37 | assert_equal domain, command.domain 38 | end 39 | end 40 | end 41 | 42 | it 'overrides domain name if set on command line' do 43 | domain = 'mysite.com' 44 | 45 | in_temporary_project do 46 | stub_config('build' => { 'domain' => 'ignored.com' }) do 47 | command = Nesta::Commands::Build.new('output_dir', 'domain' => domain) 48 | 49 | assert_equal domain, command.domain 50 | end 51 | end 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /test/integration/commands/demo/content_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | require_relative '../../../../lib/nesta/commands' 3 | 4 | describe 'nesta demo:content' do 5 | include TemporaryFiles 6 | 7 | before do 8 | Nesta::Commands::Demo::Content.demo_repository = '../../fixtures/demo-content.git' 9 | end 10 | 11 | def process_stub 12 | Object.new.tap do |stub| 13 | def stub.run(*args); end 14 | end 15 | end 16 | 17 | it 'clones the demo repository and configures project to use it' do 18 | in_temporary_project do 19 | Nesta::Commands::Demo::Content.new.execute(process_stub) 20 | 21 | yaml = File.read(File.join(project_root, 'config', 'config.yml')) 22 | assert_match /content: content-demo/, yaml 23 | end 24 | end 25 | 26 | it 'ensures demo repository is ignored by git' do 27 | in_temporary_project do 28 | FileUtils.mkdir('.git') 29 | Nesta::Commands::Demo::Content.new.execute(process_stub) 30 | assert_match /content-demo/, File.read(project_path('.git/info/exclude')) 31 | end 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /test/integration/commands/edit_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | require_relative '../../../lib/nesta/commands' 3 | 4 | describe 'nesta edit' do 5 | include TestConfiguration 6 | 7 | after do 8 | remove_temp_directory 9 | end 10 | 11 | it 'launches the editor' do 12 | ENV['EDITOR'] = 'vi' 13 | edited_file = 'path/to/page.md' 14 | process = Minitest::Mock.new 15 | process.expect(:run, true, [ENV['EDITOR'], /#{edited_file}$/]) 16 | with_temp_content_directory do 17 | command = Nesta::Commands::Edit.new(edited_file) 18 | command.execute(process) 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /test/integration/commands/new_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | require_relative '../../../lib/nesta/commands' 3 | 4 | describe 'nesta new' do 5 | include TemporaryFiles 6 | 7 | def gemfile_source 8 | File.read(project_path('Gemfile')) 9 | end 10 | 11 | def rakefile_source 12 | File.read(project_path('Rakefile')) 13 | end 14 | 15 | after do 16 | remove_temp_directory 17 | end 18 | 19 | def process_stub 20 | Object.new.tap do |stub| 21 | def stub.run(*args); end 22 | end 23 | end 24 | 25 | describe 'without options' do 26 | it 'creates the content directories' do 27 | Nesta::Commands::New.new(project_root).execute(process_stub) 28 | assert_exists_in_project 'content/attachments' 29 | assert_exists_in_project 'content/pages' 30 | end 31 | 32 | it 'creates the home page' do 33 | Nesta::Commands::New.new(project_root).execute(process_stub) 34 | assert_exists_in_project 'content/pages/index.haml' 35 | end 36 | 37 | it 'creates the rackup file' do 38 | Nesta::Commands::New.new(project_root).execute(process_stub) 39 | assert_exists_in_project 'config.ru' 40 | end 41 | 42 | it 'creates the config.yml file' do 43 | Nesta::Commands::New.new(project_root).execute(process_stub) 44 | assert_exists_in_project 'config/config.yml' 45 | end 46 | 47 | it 'creates a Gemfile' do 48 | Nesta::Commands::New.new(project_root).execute(process_stub) 49 | assert_exists_in_project 'Gemfile' 50 | assert_match /gem 'nesta'/, gemfile_source 51 | end 52 | end 53 | 54 | describe 'with --git option' do 55 | it 'creates a .gitignore file' do 56 | command = Nesta::Commands::New.new(project_root, 'git' => '') 57 | command.execute(process_stub) 58 | assert_match /\.bundle/, File.read(project_path('.gitignore')) 59 | end 60 | 61 | it 'creates a git repo' do 62 | command = Nesta::Commands::New.new(project_root, 'git' => '') 63 | process = Minitest::Mock.new 64 | process.expect(:run, true, ['git', 'init']) 65 | process.expect(:run, true, ['git', 'add', '.']) 66 | process.expect(:run, true, ['git', 'commit', '-m', 'Initial commit']) 67 | command.execute(process) 68 | end 69 | end 70 | 71 | describe 'with --vlad option' do 72 | it 'adds vlad to Gemfile' do 73 | Nesta::Commands::New.new(project_root, 'vlad' => '').execute(process_stub) 74 | assert_match /gem 'vlad', '2.1.0'/, gemfile_source 75 | assert_match /gem 'vlad-git', '2.2.0'/, gemfile_source 76 | end 77 | 78 | it 'configures the vlad rake tasks' do 79 | Nesta::Commands::New.new(project_root, 'vlad' => '').execute(process_stub) 80 | assert_exists_in_project 'Rakefile' 81 | assert_match /require 'vlad'/, rakefile_source 82 | end 83 | 84 | it 'creates deploy.rb' do 85 | Nesta::Commands::New.new(project_root, 'vlad' => '').execute(process_stub) 86 | assert_exists_in_project 'config/deploy.rb' 87 | deploy_source = File.read(project_path('config/deploy.rb')) 88 | assert_match /set :application, 'mysite.com'/, deploy_source 89 | end 90 | end 91 | end 92 | -------------------------------------------------------------------------------- /test/integration/commands/plugin/create_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | require_relative '../../../../lib/nesta/commands' 3 | 4 | describe 'nesta plugin:create' do 5 | include TemporaryFiles 6 | 7 | def working_directory 8 | temp_path('plugins') 9 | end 10 | 11 | before do 12 | FileUtils.mkdir_p(working_directory) 13 | end 14 | 15 | after do 16 | remove_temp_directory 17 | end 18 | 19 | def plugin_name 20 | 'my-feature' 21 | end 22 | 23 | def gem_name 24 | "nesta-plugin-#{plugin_name}" 25 | end 26 | 27 | def process_stub 28 | Object.new.tap do |stub| 29 | def stub.run(*args); end 30 | end 31 | end 32 | 33 | def create_plugin(&block) 34 | Dir.chdir(working_directory) do 35 | command = Nesta::Commands::Plugin::Create.new(plugin_name) 36 | command.execute(process_stub) 37 | block.call 38 | end 39 | end 40 | 41 | def assert_exists_in_plugin(path) 42 | full_path = File.join(gem_name, path) 43 | assert File.exist?(full_path), "#{path} not found in plugin" 44 | end 45 | 46 | def assert_file_contains(path, pattern) 47 | assert_match pattern, File.read(File.join(gem_name, path)) 48 | end 49 | 50 | it "creates the gem's directory" do 51 | create_plugin { assert File.directory?(gem_name), 'gem directory not found' } 52 | end 53 | 54 | it 'creates README.md file' do 55 | create_plugin { assert_exists_in_plugin('README.md') } 56 | end 57 | 58 | it 'includes installation instructions in README.md' do 59 | create_plugin do 60 | assert_file_contains('README.md', /echo 'gem "#{gem_name}"' >> Gemfile/) 61 | end 62 | end 63 | 64 | it 'creates .gitignore file' do 65 | create_plugin { assert_exists_in_plugin('.gitignore') } 66 | end 67 | 68 | it 'creates the gemspec' do 69 | create_plugin do 70 | gemspec = "#{gem_name}.gemspec" 71 | assert_exists_in_plugin(gemspec) 72 | assert_file_contains(gemspec, %r{require "#{gem_name}/version"}) 73 | assert_file_contains(gemspec, %r{Nesta::Plugin::My::Feature::VERSION}) 74 | end 75 | end 76 | 77 | it 'creates a Gemfile' do 78 | create_plugin { assert_exists_in_plugin('Gemfile') } 79 | end 80 | 81 | it 'creates Rakefile for packaging the gem' do 82 | create_plugin { assert_exists_in_plugin('Rakefile') } 83 | end 84 | 85 | it 'creates default folder for Ruby files' do 86 | create_plugin do 87 | code_directory = File.join(gem_name, 'lib', gem_name) 88 | assert File.directory?(code_directory), 'directory for code not found' 89 | end 90 | end 91 | 92 | it 'creates file required when gem loaded' do 93 | create_plugin do 94 | path = "#{File.join('lib', gem_name)}.rb" 95 | assert_exists_in_plugin(path) 96 | assert_file_contains(path, %r{require '#{gem_name}/version'}) 97 | end 98 | end 99 | 100 | it 'creates version.rb' do 101 | create_plugin do 102 | version = File.join('lib', gem_name, 'version.rb') 103 | assert_exists_in_plugin(version) 104 | assert_file_contains version, <<~EOF 105 | module Nesta 106 | module Plugin 107 | module My 108 | module Feature 109 | VERSION = '0.1.0' 110 | end 111 | end 112 | end 113 | end 114 | EOF 115 | end 116 | end 117 | 118 | it 'creates skeleton code for the plugin in init.rb' do 119 | create_plugin do 120 | init = File.join('lib', gem_name, 'init.rb') 121 | assert_exists_in_plugin(init) 122 | 123 | assert_file_contains init, <<~EOF 124 | module Nesta 125 | module Plugin 126 | module My::Feature 127 | EOF 128 | 129 | assert_file_contains init, 'helpers Nesta::Plugin::My::Feature::Helpers' 130 | end 131 | end 132 | end 133 | -------------------------------------------------------------------------------- /test/integration/commands/theme/create_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | require_relative '../../../../lib/nesta/commands' 3 | 4 | describe 'nesta theme:create' do 5 | include TemporaryFiles 6 | 7 | def theme_path(path = '') 8 | File.join('themes', 'theme-name', path) 9 | end 10 | 11 | before do 12 | FileUtils.mkdir_p(project_root) 13 | end 14 | 15 | after do 16 | remove_temp_directory 17 | end 18 | 19 | def process_stub 20 | Object.new.tap do |stub| 21 | def stub.run(*args); end 22 | end 23 | end 24 | 25 | it 'creates default files in the theme directory' do 26 | Dir.chdir(project_root) do 27 | Nesta::Commands::Theme::Create.new('theme-name').execute(process_stub) 28 | end 29 | assert_exists_in_project theme_path('README.md') 30 | assert_exists_in_project theme_path('app.rb') 31 | end 32 | 33 | it 'copies default view templates into views directory' do 34 | Dir.chdir(project_root) do 35 | Nesta::Commands::Theme::Create.new('theme-name').execute(process_stub) 36 | end 37 | %w(layout.haml page.haml master.sass).each do |template| 38 | assert_exists_in_project theme_path("views/#{template}") 39 | end 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /test/integration/commands/theme/enable_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | require_relative '../../../../lib/nesta/commands' 3 | 4 | describe 'nesta theme:enable' do 5 | include TemporaryFiles 6 | 7 | before do 8 | FileUtils.mkdir_p(File.join(project_root, 'config')) 9 | File.open(File.join(project_root, 'config', 'config.yml'), 'w').close 10 | end 11 | 12 | after do 13 | remove_temp_directory 14 | end 15 | 16 | def process_stub 17 | Object.new.tap do |stub| 18 | def stub.run(*args); end 19 | end 20 | end 21 | 22 | it 'enables the theme' do 23 | Dir.chdir(project_root) do 24 | Nesta::Commands::Theme::Enable.new('theme-name').execute(process_stub) 25 | assert_match /^theme: theme-name/, File.read('config/config.yml') 26 | end 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /test/integration/commands/theme/install_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | require_relative '../../../../lib/nesta/commands' 3 | 4 | describe 'nesta theme:install' do 5 | include TemporaryFiles 6 | 7 | def theme_name 8 | 'test' 9 | end 10 | 11 | def repo_url 12 | "../../fixtures/nesta-theme-#{theme_name}.git" 13 | end 14 | 15 | def theme_dir 16 | project_path("themes/#{theme_name}") 17 | end 18 | 19 | before do 20 | FileUtils.mkdir_p(project_root) 21 | end 22 | 23 | after do 24 | remove_temp_directory 25 | end 26 | 27 | def process_stub 28 | Object.new.tap do |stub| 29 | def stub.run(*args); end 30 | end 31 | end 32 | 33 | it 'clones the repository' do 34 | process = Minitest::Mock.new 35 | process.expect(:run, true, ['git', 'clone', repo_url, "themes/#{theme_name}"]) 36 | in_temporary_project do 37 | Nesta::Commands::Theme::Install.new(repo_url).execute(process) 38 | end 39 | end 40 | 41 | it "removes the theme's .git directory" do 42 | in_temporary_project do 43 | Nesta::Commands::Theme::Install.new(repo_url).execute(process_stub) 44 | refute File.exist?("#{theme_dir}/.git"), '.git folder found' 45 | end 46 | end 47 | 48 | it 'enables the freshly installed theme' do 49 | in_temporary_project do 50 | Nesta::Commands::Theme::Install.new(repo_url).execute(process_stub) 51 | assert_match /theme: #{theme_name}/, File.read('config/config.yml') 52 | end 53 | end 54 | 55 | it 'determines name of theme from name of repository' do 56 | url = 'https://foobar.com/path/to/nesta-theme-the-name.git' 57 | command = Nesta::Commands::Theme::Install.new(url) 58 | assert_equal 'the-name', command.theme_name 59 | end 60 | 61 | it "falls back to name of repo when theme name doesn't match correct format" do 62 | url = 'https://foobar.com/path/to/mytheme.git' 63 | command = Nesta::Commands::Theme::Install.new(url) 64 | assert_equal 'mytheme', command.theme_name 65 | end 66 | end 67 | -------------------------------------------------------------------------------- /test/integration/default_theme_test.rb: -------------------------------------------------------------------------------- 1 | require 'integration_test_helper' 2 | 3 | describe 'Default theme' do 4 | include Nesta::IntegrationTest 5 | 6 | def in_categories(*categories) 7 | { 'categories' => categories.map(&:path).join(', ') } 8 | end 9 | 10 | it 'includes description meta tag' do 11 | with_temp_content_directory do 12 | model = create(:page, metadata: { 'Description' => 'A good page' }) 13 | visit model.path 14 | xpath = "//meta[@name='description'][@content='A good page']" 15 | assert_has_xpath xpath, visible: false 16 | end 17 | end 18 | 19 | it 'includes keywords meta tag' do 20 | with_temp_content_directory do 21 | model = create(:page, metadata: { 'Keywords' => 'good, content' }) 22 | visit model.path 23 | xpath = "//meta[@name='keywords'][@content='good, content']" 24 | assert_has_xpath xpath, visible: false 25 | end 26 | end 27 | 28 | it "doesn't include Google Analytics JavaScript snippet by default" do 29 | with_temp_content_directory do 30 | visit '/' 31 | assert_has_no_css 'script', text: 'google-analytics.com' 32 | end 33 | end 34 | 35 | it 'includes Google Analytics JavaScript when configured' do 36 | analytics_code = { 'google_analytics_code' => 'G-1234' } 37 | stub_config(temp_content.merge('test' => analytics_code)) do 38 | visit '/' 39 | assert_nil all('script').find { |s| s[:src].match /analytics\.js/ } 40 | end 41 | end 42 | 43 | it 'displays site title' do 44 | site_config = { 45 | 'title' => 'My blog', 46 | 'subtitle' => 'about stuff', 47 | } 48 | stub_config(temp_content.merge(site_config)) do 49 | visit '/' 50 | assert_has_css 'h1', text: 'My blog' 51 | assert_has_css 'h2', text: 'about stuff' 52 | end 53 | end 54 | 55 | describe 'menus' do 56 | def create_pages_in_menu 57 | pages 58 | end 59 | 60 | it "doesn't include menu markup if menu not configured" do 61 | with_temp_content_directory do 62 | visit '/' 63 | assert_has_no_css 'ul.menu' 64 | end 65 | end 66 | 67 | it 'only displays first two levels of menu items' do 68 | with_temp_content_directory do 69 | level1, level2, level3 = (0..2).map { create(:page) } 70 | text = "%s\n %s\n %s\n" % [level1, level2, level3].map(&:abspath) 71 | create_menu(text) 72 | visit '/' 73 | assert_has_css "ul.menu li a:contains('#{level1.link_text}')" 74 | assert_has_css "ul.menu li ul li a:contains('#{level2.link_text}')" 75 | assert_has_no_css "ul.menu a:contains('#{level3.link_text}')" 76 | end 77 | end 78 | 79 | def create_page_and_menu 80 | model = create(:page) 81 | create_menu(model.path) 82 | model 83 | end 84 | 85 | it "highlights current page's menu item" do 86 | with_temp_content_directory do 87 | model = create_page_and_menu 88 | visit model.path 89 | assert_has_css "ul.menu li.current:contains('#{model.link_text}')" 90 | end 91 | end 92 | 93 | it "highlights current page's menu item when app mounted at /prefix" do 94 | Capybara.app = Rack::Builder.new do 95 | map '/prefix' do 96 | run Nesta::App.new 97 | end 98 | end 99 | 100 | with_temp_content_directory do 101 | model = create_page_and_menu 102 | visit "/prefix/#{model.path}" 103 | assert_has_css "ul.menu li.current:contains('#{model.link_text}')" 104 | end 105 | end 106 | end 107 | 108 | it 'only displays read more link for summarised pages' do 109 | with_temp_content_directory do 110 | category = create(:category) 111 | metadata = in_categories(category) 112 | summarised = create(:page, metadata: metadata.merge('summary' => 'Summary')) 113 | not_summarised = create(:page, metadata: metadata) 114 | visit category.path 115 | assert_has_css 'li:nth-child(1) p', text: summarised.read_more 116 | assert_has_no_css 'li:nth-child(2) p', text: not_summarised.read_more 117 | end 118 | end 119 | 120 | it 'displays page summaries or full content of unsummarised pages' do 121 | with_temp_content_directory do 122 | category = create(:category) 123 | metadata = in_categories(category) 124 | summarised = create(:page, 125 | content: 'Summarised content', 126 | metadata: metadata.merge(summary: 'Summary')) 127 | not_summarised = create(:page, 128 | content: 'Unsummarised content', 129 | metadata: metadata) 130 | visit category.path 131 | 132 | # Page with a summary 133 | assert_has_css 'li:nth-child(1) p', text: summarised.metadata(:content) 134 | assert_has_no_css 'li:nth-child(1) p', text: 'content' 135 | 136 | # Page without a summary 137 | assert_has_css 'li:nth-child(2) p', text: not_summarised.metadata(:content) 138 | assert_has_no_css 'li:nth-child(2) p', text: 'Summary' 139 | end 140 | end 141 | 142 | it 'displays contents of page' do 143 | with_temp_content_directory do 144 | model = create(:page, content: 'Body of page') 145 | visit model.path 146 | assert_has_css 'p', text: 'Body of page' 147 | end 148 | end 149 | 150 | describe 'category' do 151 | it 'displays its "articles heading" above the articles' do 152 | with_temp_content_directory do 153 | category = create(:category, metadata: { 154 | 'articles heading' => 'Articles on this topic' 155 | }) 156 | create(:article, metadata: in_categories(category)) 157 | visit category.path 158 | assert_has_css 'h1', text: 'Articles on this topic' 159 | end 160 | end 161 | 162 | it 'links to articles in category using article title' do 163 | with_temp_content_directory do 164 | category = create(:category) 165 | article = create(:article, metadata: in_categories(category)) 166 | visit category.path 167 | link_text, href = article.link_text, article.abspath 168 | assert_has_css "ol h1 a[href$='#{href}']", text: link_text 169 | end 170 | end 171 | end 172 | 173 | describe 'article' do 174 | it 'displays the date' do 175 | with_temp_content_directory do 176 | article = create(:article) 177 | visit article.path 178 | assert_has_css "time[datetime='#{article.date}']" 179 | end 180 | end 181 | 182 | it 'links to parent page in breadcrumb' do 183 | with_temp_content_directory do 184 | parent = create(:category) 185 | article = create(:article, path: "#{parent.path}/child") 186 | visit article.path 187 | href, link_text = parent.abspath, parent.link_text 188 | assert_has_css "nav.breadcrumb a[href='#{href}']", text: link_text 189 | end 190 | end 191 | 192 | it 'links to its categories at end of article' do 193 | with_temp_content_directory do 194 | categories = [create(:category), create(:category)] 195 | article = create(:article, metadata: in_categories(*categories)) 196 | visit article.path 197 | categories.each do |category| 198 | href, link_text = category.abspath, category.link_text 199 | assert_has_css "p.meta a[href$='#{href}']", text: link_text 200 | end 201 | end 202 | end 203 | 204 | it 'displays comments from Disqus' do 205 | stub_config(temp_content.merge('disqus_short_name' => 'mysite')) do 206 | article = create(:article) 207 | visit article.path 208 | assert_has_css '#disqus_thread' 209 | assert_has_css 'script[src*="mysite.disqus.com"]', visible: false 210 | end 211 | end 212 | 213 | it "doesn't use Disqus if it's not configured" do 214 | with_temp_content_directory do 215 | visit create(:article).path 216 | assert_has_no_css '#disqus_thread' 217 | end 218 | end 219 | end 220 | end 221 | -------------------------------------------------------------------------------- /test/integration/overrides_test.rb: -------------------------------------------------------------------------------- 1 | require 'integration_test_helper' 2 | 3 | describe 'Overriding files in gem and themes' do 4 | include Nesta::IntegrationTest 5 | 6 | def in_nesta_project(config = {}, &block) 7 | app_root = temp_path('root') 8 | content_config = { 'content' => File.join(app_root, 'content') } 9 | stub_config(content_config.merge(config)) do 10 | with_app_root(app_root) do 11 | block.call 12 | end 13 | end 14 | ensure 15 | remove_temp_directory 16 | end 17 | 18 | def theme_name 19 | 'my-theme' 20 | end 21 | 22 | def create_fixture(type, name, content) 23 | base_path = { 24 | local: Nesta::Path.local, 25 | theme: Nesta::Path.themes(theme_name) 26 | }[type] 27 | path = File.join(base_path, name) 28 | FileUtils.mkdir_p(File.dirname(path)) 29 | File.open(path, 'w') { |file| file.write(content) } 30 | end 31 | 32 | def create_app_file(type) 33 | create_fixture(type, 'app.rb', "FROM_#{type.to_s.upcase} = true") 34 | end 35 | 36 | describe 'app.rb' do 37 | it 'loads app.rb from configured theme' do 38 | in_nesta_project('theme' => theme_name) do 39 | create_app_file(:theme) 40 | Nesta::Overrides.load_theme_app 41 | assert Object.const_get(:FROM_THEME), 'should load app.rb in theme' 42 | end 43 | end 44 | 45 | it 'loads both local and theme app.rb files' do 46 | in_nesta_project('theme' => theme_name) do 47 | create_app_file(:local) 48 | Nesta::Overrides.load_local_app 49 | create_app_file(:theme) 50 | Nesta::Overrides.load_theme_app 51 | assert Object.const_get(:FROM_THEME), 'should load app.rb in theme' 52 | assert Object.const_get(:FROM_LOCAL), 'should load local app.rb' 53 | end 54 | end 55 | end 56 | 57 | def create_view(type, name, content) 58 | create_fixture(type, File.join('views', name), content) 59 | end 60 | 61 | describe 'rendering stylesheets' do 62 | it 'renders Sass stylesheets' do 63 | in_nesta_project do 64 | create_view(:local, 'master.sass', "body\n width: 10px * 2") 65 | visit '/css/master.css' 66 | assert_match /width: 20px;/, body, 'should match /width: 20px;/' 67 | end 68 | end 69 | 70 | it 'renders SCSS stylesheets' do 71 | in_nesta_project do 72 | create_view(:local, 'master.scss', "body {\n width: 10px * 2;\n}") 73 | visit '/css/master.css' 74 | assert_match /width: 20px;/, body, 'should match /width: 20px;/' 75 | end 76 | end 77 | 78 | it 'renders stylesheet in the gem if no others found' do 79 | in_nesta_project do 80 | visit '/css/master.css' 81 | assert_equal 200, page.status_code 82 | end 83 | end 84 | end 85 | 86 | def create_haml(type, name, content) 87 | create_view(type, 'layout.haml', '= yield') 88 | create_view(type, name, content) 89 | end 90 | 91 | describe 'rendering Haml' do 92 | it 'uses local template in place of default' do 93 | in_nesta_project do 94 | create_haml(:local, 'page.haml', '%p Local template') 95 | visit create(:category).abspath 96 | assert_has_xpath '//p', text: 'Local template' 97 | end 98 | end 99 | 100 | it 'uses theme template in place of default' do 101 | in_nesta_project('theme' => theme_name) do 102 | create_haml(:theme, 'page.haml', '%p Theme template') 103 | visit create(:category).abspath 104 | assert_has_xpath '//p', text: 'Theme template' 105 | end 106 | end 107 | 108 | it 'prioritise local templates over theme templates' do 109 | in_nesta_project('theme' => theme_name) do 110 | create_haml(:local, 'page.haml', '%p Local template') 111 | create_haml(:theme, 'page.haml', '%p Theme template') 112 | visit create(:category).abspath 113 | assert_has_xpath '//p', text: 'Local template' 114 | assert_has_no_xpath '//p', text: 'Theme template' 115 | end 116 | end 117 | end 118 | end 119 | -------------------------------------------------------------------------------- /test/integration/route_handlers_test.rb: -------------------------------------------------------------------------------- 1 | require 'integration_test_helper' 2 | 3 | describe 'Routing' do 4 | include Nesta::IntegrationTest 5 | 6 | it 'redirects requests with trailing slash' do 7 | with_temp_content_directory do 8 | model = create(:page) 9 | visit model.path + '/' 10 | assert_equal model.abspath, page.current_path 11 | end 12 | end 13 | 14 | describe 'not_found handler' do 15 | it 'returns HTTP code 404' do 16 | with_temp_content_directory do 17 | visit '/no-such-page' 18 | assert_equal 404, page.status_code 19 | end 20 | end 21 | end 22 | 23 | describe 'default route' do 24 | it 'provides access to helper methods in Haml pages' do 25 | with_temp_content_directory do 26 | model = create(:category, 27 | ext: 'haml', 28 | content: '%div= format_date(Date.new(2010, 11, 23))') 29 | visit model.path 30 | assert_has_css 'div', text: '23 November 2010' 31 | end 32 | end 33 | 34 | it 'should access helpers when rendering articles on a category page' do 35 | with_temp_content_directory do 36 | category = create(:page) 37 | create( 38 | :article, 39 | ext: 'haml', 40 | metadata: { 'categories' => category.path }, 41 | content: "%h1 Heading\n\n%div= format_date(Date.new(2010, 11, 23))" 42 | ) 43 | 44 | visit category.path 45 | 46 | assert_has_css 'div', text: '23 November 2010' 47 | end 48 | end 49 | end 50 | 51 | def create_attachment_in(directory) 52 | path = File.join(directory, 'test.txt') 53 | FileUtils.mkdir_p(directory) 54 | File.open(path, 'w') { |file| file.write("I'm a test attachment") } 55 | end 56 | 57 | describe 'attachments route' do 58 | it 'returns HTTP code 200' do 59 | with_temp_content_directory do 60 | create_attachment_in(Nesta::Config.attachment_path) 61 | visit '/attachments/test.txt' 62 | assert_equal 200, page.status_code 63 | end 64 | end 65 | 66 | it 'serves attachment to the client' do 67 | with_temp_content_directory do 68 | create_attachment_in(Nesta::Config.attachment_path) 69 | visit '/attachments/test.txt' 70 | assert_equal "I'm a test attachment", page.body 71 | end 72 | end 73 | 74 | it 'sets the appropriate MIME type' do 75 | with_temp_content_directory do 76 | create_attachment_in(Nesta::Config.attachment_path) 77 | visit '/attachments/test.txt' 78 | assert_match %r{^text/plain}, page.response_headers['Content-Type'] 79 | end 80 | end 81 | 82 | it 'refuses to serve files outside the attachments directory' do 83 | # On earlier versions of Sinatra this test would have been handed 84 | # to the attachments handler. On the current version (1.4.5) it 85 | # appears as though the request is no longer matched by the 86 | # attachments route handler, which means we're not in danger of 87 | # serving serving files to attackers via `#send_file`. 88 | # 89 | # I've left the test in anyway, as without it we wouldn't become 90 | # aware of a security hole if there was a regression in Sinatra. 91 | # 92 | with_temp_content_directory do 93 | model = create(:page) 94 | visit "/attachments/../pages/#{File.basename(model.filename)}" 95 | assert_equal 404, page.status_code 96 | end 97 | end 98 | end 99 | end 100 | -------------------------------------------------------------------------------- /test/integration/sitemap_test.rb: -------------------------------------------------------------------------------- 1 | require 'integration_test_helper' 2 | 3 | describe 'XML sitemap' do 4 | include Nesta::IntegrationTest 5 | 6 | def visit_sitemap 7 | visit '/sitemap.xml' 8 | end 9 | 10 | def for_site_with_page(&block) 11 | with_temp_content_directory do 12 | model = create(:page) 13 | visit_sitemap 14 | block.call(model) 15 | end 16 | end 17 | 18 | def for_site_with_article(&block) 19 | with_temp_content_directory do 20 | article = create(:article) 21 | visit_sitemap 22 | block.call(article) 23 | end 24 | end 25 | 26 | it 'renders successfully' do 27 | for_site_with_page do 28 | assert_equal 200, page.status_code 29 | end 30 | end 31 | 32 | it 'has a urlset tag' do 33 | for_site_with_page do 34 | namespace = 'https://www.sitemaps.org/schemas/sitemap/0.9' 35 | assert_has_xpath "//urlset[@xmlns='#{namespace}']" 36 | end 37 | end 38 | 39 | it 'references the home page' do 40 | for_site_with_page do 41 | assert_has_xpath '//urlset/url/loc', text: 'http://www.example.com/' 42 | end 43 | end 44 | 45 | it 'configures home page to be checked frequently' do 46 | for_site_with_page do 47 | assert_has_xpath '//urlset/url/loc', text: 'http://www.example.com/' 48 | assert_has_xpath '//urlset/url/changefreq', text: 'daily' 49 | assert_has_xpath '//urlset/url/priority', text: '1.0' 50 | end 51 | end 52 | 53 | it 'sets homepage lastmod from timestamp of most recently modified page' do 54 | for_site_with_article do |article| 55 | timestamp = article.last_modified 56 | assert_has_xpath '//urlset/url/loc', text: 'http://www.example.com/' 57 | assert_has_xpath '//urlset/url/lastmod', text: timestamp.xmlschema 58 | end 59 | end 60 | 61 | def site_url(path) 62 | "http://www.example.com/#{path}" 63 | end 64 | 65 | it 'references category pages' do 66 | for_site_with_page do |model| 67 | assert_has_xpath '//urlset/url/loc', text: site_url(model.path) 68 | end 69 | end 70 | 71 | it 'references article pages' do 72 | for_site_with_article do |article| 73 | assert_has_xpath '//urlset/url/loc', text: site_url(article.path) 74 | end 75 | end 76 | 77 | it 'omits pages that have the skip-sitemap flag set' do 78 | with_temp_content_directory do 79 | create(:category) 80 | omitted = create(:page, metadata: { 'flags' => 'skip-sitemap' }) 81 | visit_sitemap 82 | assert_has_no_xpath '//urlset/url/loc', text: site_url(omitted.path) 83 | end 84 | end 85 | end 86 | -------------------------------------------------------------------------------- /test/integration_test_helper.rb: -------------------------------------------------------------------------------- 1 | require 'capybara/dsl' 2 | 3 | require_relative 'test_helper' 4 | 5 | module Nesta 6 | class App < Sinatra::Base 7 | set :environment, :test 8 | set :reload_templates, true 9 | end 10 | 11 | module IntegrationTest 12 | include Capybara::DSL 13 | 14 | include ModelFactory 15 | include TestConfiguration 16 | 17 | def setup 18 | Capybara.app = App.new 19 | end 20 | 21 | def teardown 22 | Capybara.reset_sessions! 23 | Capybara.use_default_driver 24 | 25 | remove_temp_directory 26 | Nesta::FileModel.purge_cache 27 | end 28 | 29 | def assert_has_xpath(query, options = {}) 30 | if ! page.has_xpath?(query, options) 31 | message = "not found in page: '#{query}'" 32 | message << ", #{options.inspect}" unless options.empty? 33 | fail message 34 | end 35 | end 36 | 37 | def assert_has_no_xpath(query, options = {}) 38 | if page.has_xpath?(query, options) 39 | message = "found in page: '#{query}'" 40 | message << ", #{options.inspect}" unless options.empty? 41 | fail message 42 | end 43 | end 44 | 45 | def assert_has_css(query, options = {}) 46 | if ! page.has_css?(query, options) 47 | message = "not found in page: '#{query}'" 48 | message << ", #{options.inspect}" unless options.empty? 49 | fail message 50 | end 51 | end 52 | 53 | def assert_has_no_css(query, options = {}) 54 | if ! page.has_no_css?(query, options) 55 | message = "found in page: '#{query}'" 56 | message << ", #{options.inspect}" unless options.empty? 57 | fail message 58 | end 59 | end 60 | end 61 | end 62 | -------------------------------------------------------------------------------- /test/support/model_factory.rb: -------------------------------------------------------------------------------- 1 | module ModelFactory 2 | class FileModelWriter 3 | def initialize(type, data = {}) 4 | @type = type 5 | @data = data 6 | end 7 | 8 | def model_class 9 | Nesta::FileModel 10 | end 11 | 12 | def instantiate_model 13 | model_class.new(filename) 14 | end 15 | 16 | def extension 17 | data.fetch(:ext, 'mdown') 18 | end 19 | 20 | def filename 21 | File.join(model_class.model_path, "#{data[:path]}.#{extension}") 22 | end 23 | 24 | def sequence_prefix 25 | 'file-' 26 | end 27 | 28 | def default_data 29 | { path: default_path } 30 | end 31 | 32 | def default_path 33 | sequence_prefix + ModelFactory.next_sequence_number.to_s 34 | end 35 | 36 | def data 37 | if @memoized_data.nil? 38 | @memoized_data = default_data 39 | if @memoized_data.has_key?(:metadata) 40 | @memoized_data[:metadata].merge!(@data.delete(:metadata) || {}) 41 | end 42 | @memoized_data.merge!(@data) 43 | end 44 | @memoized_data 45 | end 46 | 47 | def write 48 | metadata = data[:metadata] || {} 49 | metatext = metadata.map { |key, value| "#{key}: #{value}" }.join("\n") 50 | contents = <<~EOF 51 | #{metatext} 52 | 53 | #{heading}#{data[:content]} 54 | EOF 55 | FileUtils.mkdir_p(File.dirname(filename)) 56 | File.open(filename, 'w') { |file| file.write(contents) } 57 | yield(filename) if block_given? 58 | end 59 | 60 | def heading 61 | return '' unless data[:heading] 62 | prefix = { 63 | 'haml' => "%div\n %h1", 64 | 'textile' => "
\nh1." 65 | }.fetch(data[:ext], '# ') 66 | "#{prefix} #{data[:heading]}\n\n" 67 | end 68 | end 69 | 70 | class PageWriter < FileModelWriter 71 | def model_class 72 | Nesta::Page 73 | end 74 | 75 | def sequence_prefix 76 | 'page-' 77 | end 78 | 79 | def default_data 80 | path = default_path 81 | { path: path, heading: heading_from_path(path) } 82 | end 83 | 84 | def heading_from_path(path) 85 | File.basename(path).sub('-', ' ').capitalize 86 | end 87 | end 88 | 89 | class ArticleWriter < PageWriter 90 | def sequence_prefix 91 | 'articles/page-' 92 | end 93 | 94 | def default_data 95 | path = default_path 96 | { 97 | path: path, 98 | heading: heading_from_path(path), 99 | content: 'Content goes here', 100 | metadata: { 101 | 'date' => '29 December 2008' 102 | } 103 | } 104 | end 105 | end 106 | 107 | class CategoryWriter < PageWriter 108 | def sequence_prefix 109 | 'categories/page-' 110 | end 111 | 112 | def default_data 113 | path = default_path 114 | { 115 | path: path, 116 | heading: heading_from_path(path), 117 | content: 'Content goes here' 118 | } 119 | end 120 | end 121 | 122 | @@sequence = 0 123 | def self.next_sequence_number 124 | @@sequence += 1 125 | end 126 | 127 | def before_setup 128 | # This is a minitest hook. We reset file sequence number at the 129 | # start of each test so that we can automatically generate a unique 130 | # path for each file we create within a test. 131 | @@sequence = 0 132 | end 133 | 134 | def create(type, data = {}, &block) 135 | file_writer = writer_class(type).new(type, data) 136 | file_writer.write(&block) 137 | file_writer.instantiate_model 138 | end 139 | 140 | def write_menu_item(indent, file, menu_item) 141 | if menu_item.is_a?(Array) 142 | indent.sub!(/^/, ' ') 143 | menu_item.each { |path| write_menu_item(indent, file, path) } 144 | indent.sub!(/^ /, '') 145 | else 146 | file.write("#{indent}#{menu_item}\n") 147 | end 148 | end 149 | 150 | def create_menu(menu_text) 151 | file = filename(Nesta::Config.content_path, 'menu', :txt) 152 | File.open(file, 'w') { |file| file.write(menu_text) } 153 | end 154 | 155 | def create_content_directories 156 | FileUtils.mkdir_p(Nesta::Config.page_path) 157 | FileUtils.mkdir_p(Nesta::Config.attachment_path) 158 | end 159 | 160 | private 161 | 162 | def filename(directory, basename, extension = :mdown) 163 | File.join(directory, "#{basename}.#{extension}") 164 | end 165 | 166 | def writer_class(type) 167 | camelcased_type = type.to_s.gsub(/(?:^|_)(\w)/) { $1.upcase } 168 | ModelFactory.const_get(camelcased_type + 'Writer') 169 | end 170 | end 171 | -------------------------------------------------------------------------------- /test/support/temporary_files.rb: -------------------------------------------------------------------------------- 1 | require 'fileutils' 2 | 3 | module TemporaryFiles 4 | TEMP_DIR = File.expand_path('tmp', File.join(File.dirname(__FILE__), '..')) 5 | 6 | def remove_temp_directory 7 | if File.exist?(TemporaryFiles::TEMP_DIR) 8 | FileUtils.rm_r(TemporaryFiles::TEMP_DIR) 9 | end 10 | end 11 | 12 | def temp_path(base) 13 | File.join(TemporaryFiles::TEMP_DIR, base) 14 | end 15 | 16 | def project_root 17 | temp_path('mysite.com') 18 | end 19 | 20 | def project_path(path) 21 | File.join(project_root, path) 22 | end 23 | 24 | def in_temporary_project(*args, &block) 25 | FileUtils.mkdir_p(File.join(project_root, 'config')) 26 | FileUtils.touch(File.join(project_root, 'Gemfile')) 27 | FileUtils.touch(File.join(project_root, 'config', 'config.yml')) 28 | Dir.chdir(project_root) { block.call(project_root) } 29 | ensure 30 | remove_temp_directory 31 | end 32 | 33 | def assert_exists_in_project(path) 34 | assert File.exist?(project_path(path)), "#{path} should exist" 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /test/support/test_configuration.rb: -------------------------------------------------------------------------------- 1 | module TestConfiguration 2 | include TemporaryFiles 3 | 4 | def stub_config(config, &block) 5 | Nesta::Config.instance.stub(:config, config) do 6 | block.call 7 | end 8 | end 9 | 10 | def temp_content 11 | { 'content' => temp_path('content') } 12 | end 13 | 14 | def with_temp_content_directory(&block) 15 | stub_config(temp_content) { block.call } 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /test/test_helper.rb: -------------------------------------------------------------------------------- 1 | require 'sinatra' 2 | require 'minitest/autorun' 3 | 4 | require 'minitest/reporters' 5 | 6 | reporter_setting = ENV.fetch('REPORTER', 'progress') 7 | camel_case = reporter_setting.split(/_/).map { |word| word.capitalize }.join('') 8 | Minitest::Reporters.use! Minitest::Reporters.const_get("#{camel_case}Reporter").new 9 | 10 | require File.expand_path('../lib/nesta/env', File.dirname(__FILE__)) 11 | require File.expand_path('../lib/nesta/app', File.dirname(__FILE__)) 12 | 13 | require_relative 'support/model_factory' 14 | require_relative 'support/temporary_files' 15 | require_relative 'support/test_configuration' 16 | 17 | Nesta::App.environment = 'test' 18 | 19 | class Minitest::Test 20 | def with_app_root(path, &block) 21 | original, Nesta::App.root = Nesta::App.root, path 22 | block.call 23 | ensure 24 | Nesta::App.root = original 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /test/unit/config_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | 3 | describe Nesta::Config do 4 | include TestConfiguration 5 | 6 | it 'cannot be instantiated directly' do 7 | assert_raises(NoMethodError) { Nesta::Config.new } 8 | end 9 | 10 | it 'exposes settings at the class level' do 11 | Nesta::Config::SETTINGS.each do |setting| 12 | assert Nesta::Config.respond_to?(setting), "should respond to '#{setting}'" 13 | end 14 | end 15 | 16 | it 'defines default value for "Read more"' do 17 | assert_equal 'Continue reading', Nesta::Config.read_more 18 | end 19 | 20 | it 'returns nil for author when not defined' do 21 | assert_nil Nesta::Config.author 22 | end 23 | 24 | it 'reads configuration from YAML' do 25 | title = 'Site Title' 26 | stub_config('subtitle' => title) do 27 | assert_equal title, Nesta::Config.subtitle 28 | end 29 | end 30 | 31 | it 'sets author hash from YAML' do 32 | name = 'Name from YAML' 33 | uri = 'URI from YAML' 34 | stub_config('author' => { 'name' => name, 'uri' => uri }) do 35 | assert_equal name, Nesta::Config.author['name'] 36 | assert_equal uri, Nesta::Config.author['uri'] 37 | assert Nesta::Config.author['email'].nil?, 'should be nil' 38 | end 39 | end 40 | 41 | it 'returns environment specific settings' do 42 | stub_config('test' => { 'content' => 'rack_env_specific/path' }) do 43 | assert_equal 'rack_env_specific/path', Nesta::Config.content 44 | end 45 | end 46 | 47 | it 'overrides top level settings with environment specific settings' do 48 | config = { 49 | 'content' => 'general/path', 50 | 'test' => { 'content' => 'rack_env_specific/path' } 51 | } 52 | stub_config(config) do 53 | assert_equal 'rack_env_specific/path', Nesta::Config.content 54 | end 55 | end 56 | 57 | describe 'Nesta::Config.fetch' do 58 | it 'retrieves settings from YAML' do 59 | stub_config('my_setting' => 'value in YAML') do 60 | assert_equal 'value in YAML', Nesta::Config.fetch('my_setting') 61 | assert_equal 'value in YAML', Nesta::Config.fetch(:my_setting) 62 | end 63 | end 64 | 65 | it "throws NotDefined if a setting isn't defined" do 66 | assert_raises(Nesta::Config::NotDefined) do 67 | Nesta::Config.fetch('no such setting') 68 | end 69 | end 70 | 71 | it 'allows default values to be set' do 72 | assert_equal 'default', Nesta::Config.fetch('no such setting', 'default') 73 | end 74 | end 75 | end 76 | -------------------------------------------------------------------------------- /test/unit/models/file_model_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | 3 | describe Nesta::FileModel do 4 | include ModelFactory 5 | include TestConfiguration 6 | 7 | after do 8 | Nesta::FileModel.purge_cache 9 | remove_temp_directory 10 | end 11 | 12 | it 'can find all files of this type' do 13 | with_temp_content_directory do 14 | model = create(:file_model) 15 | assert_equal [model], Nesta::FileModel.find_all 16 | end 17 | end 18 | 19 | describe '.find_file_for_path' do 20 | it 'returns filename for path' do 21 | with_temp_content_directory do 22 | model = create(:file_model) 23 | filename = Nesta::FileModel.find_file_for_path(model.path) 24 | assert_equal filename, model.filename 25 | end 26 | end 27 | 28 | it 'returns nil if file not found' do 29 | with_temp_content_directory do 30 | assert_nil Nesta::FileModel.find_file_for_path('foobar') 31 | end 32 | end 33 | end 34 | 35 | it 'can parse metadata at top of a file' do 36 | with_temp_content_directory do 37 | model = create(:file_model) 38 | metadata = model.parse_metadata('My key: some value') 39 | assert_equal 'some value', metadata['my key'] 40 | end 41 | end 42 | 43 | it "doesn't break loading pages with badly formatted metadata" do 44 | with_temp_content_directory do 45 | dodgy_metadata = "Key: value\nKey without value\nAnother key: value" 46 | page = create(:page) do |path| 47 | text = File.read(path) 48 | File.open(path, 'w') do |file| 49 | file.puts(dodgy_metadata) 50 | file.write(text) 51 | end 52 | end 53 | Nesta::Page.find_by_path(page.path) 54 | end 55 | end 56 | 57 | it 'invalidates cached models when files are modified' do 58 | with_temp_content_directory do 59 | create(:file_model, path: 'a-page', metadata: { 'Version' => '1' }) 60 | now = Time.now 61 | File.stub(:mtime, now - 1) do 62 | Nesta::FileModel.load('a-page') 63 | end 64 | create(:file_model, path: 'a-page', metadata: { 'Version' => '2' }) 65 | model = File.stub(:mtime, now) do 66 | Nesta::FileModel.load('a-page') 67 | end 68 | assert_equal '2', model.metadata('version') 69 | end 70 | end 71 | end 72 | -------------------------------------------------------------------------------- /test/unit/models/menu_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | 3 | describe Nesta::Menu do 4 | include ModelFactory 5 | include TestConfiguration 6 | 7 | def with_page(&block) 8 | with_temp_content_directory do 9 | page = create(:page) 10 | create_menu(page.path) 11 | block.call(page) 12 | end 13 | end 14 | 15 | def with_hierarchy_of_pages(&block) 16 | with_temp_content_directory do 17 | pages = (1..6).map { |_| create(:page) } 18 | text = <<~EOF 19 | #{pages[0].path} 20 | #{pages[1].path} 21 | #{pages[2].path} 22 | #{pages[3].path} 23 | "no-such-page" 24 | "another-missing-page" 25 | #{pages[4].path} 26 | #{pages[5].path} 27 | EOF 28 | create_menu(text) 29 | block.call(pages) 30 | end 31 | end 32 | 33 | after do 34 | remove_temp_directory 35 | Nesta::FileModel.purge_cache 36 | end 37 | 38 | it 'retrieves Page objects for the menu' do 39 | with_page do |page| 40 | assert_equal [page], Nesta::Menu.full_menu 41 | assert_equal [page], Nesta::Menu.for_path('/') 42 | end 43 | end 44 | 45 | it "filters pages that don't exist out of the menu" do 46 | with_temp_content_directory do 47 | page = create(:page) 48 | text = ['no-such-page', page.path].join("\n") 49 | create_menu(text) 50 | assert_equal [page], Nesta::Menu.top_level 51 | end 52 | end 53 | 54 | describe 'nested sub menus' do 55 | it 'returns top level menu items' do 56 | with_hierarchy_of_pages do |pages| 57 | assert_equal [pages[0], pages[4]], Nesta::Menu.top_level 58 | end 59 | end 60 | 61 | it 'returns full tree of menu items' do 62 | with_hierarchy_of_pages do |pages| 63 | page1, page2, page3, page4, page5, page6 = pages 64 | expected = [page1, [page2, [page3, page4]], page5, [page6]] 65 | assert_equal expected, Nesta::Menu.full_menu 66 | end 67 | end 68 | 69 | it 'returns part of the tree of menu items' do 70 | with_hierarchy_of_pages do |pages| 71 | _, page2, page3, page4 = pages 72 | assert_equal [page2, [page3, page4]], Nesta::Menu.for_path(page2.path) 73 | end 74 | end 75 | 76 | it 'deems menu for path not in menu to be nil' do 77 | with_hierarchy_of_pages do 78 | assert_nil Nesta::Menu.for_path('wibble') 79 | end 80 | end 81 | end 82 | end 83 | -------------------------------------------------------------------------------- /test/unit/models/page_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | 3 | describe Nesta::Page do 4 | include ModelFactory 5 | include TestConfiguration 6 | 7 | after do 8 | Nesta::FileModel.purge_cache 9 | remove_temp_directory 10 | end 11 | 12 | it 'raises error if instantiated for non existant file' do 13 | with_temp_content_directory do 14 | assert_raises(Sinatra::NotFound) do 15 | Nesta::Page.new('no-such-file') 16 | end 17 | end 18 | end 19 | 20 | describe '.find_by_path' do 21 | it 'finds model instances by path' do 22 | with_temp_content_directory do 23 | page = create(:page) 24 | assert_equal page.heading, Nesta::Page.find_by_path(page.path).heading 25 | end 26 | end 27 | 28 | it 'finds model for index page by path' do 29 | with_temp_content_directory do 30 | page = create(:page, path: 'path/index') 31 | assert_equal page.heading, Nesta::Page.find_by_path('path').heading 32 | end 33 | end 34 | 35 | it 'finds model for home page when path is /' do 36 | with_temp_content_directory do 37 | create(:page, heading: 'Home', path: 'index') 38 | assert_equal 'Home', Nesta::Page.find_by_path('/').title 39 | end 40 | end 41 | 42 | it 'returns nil if page not found' do 43 | with_temp_content_directory do 44 | assert_nil Nesta::Page.find_by_path('no-such-page') 45 | end 46 | end 47 | 48 | it 'returns nil for draft pages when running in production' do 49 | with_temp_content_directory do 50 | draft = create(:page, metadata: { 'flags' => 'draft' }) 51 | assert Nesta::Page.find_by_path(draft.path), 'should find draft' 52 | Nesta::App.stub(:production?, true) do 53 | assert_nil Nesta::Page.find_by_path(draft.path) 54 | end 55 | end 56 | end 57 | end 58 | 59 | describe '.find_articles' do 60 | it "doesn't return articles with a published date in the future" do 61 | with_temp_content_directory do 62 | future_date = (Time.now + 172800).strftime('%d %B %Y') 63 | article = create(:article, metadata: { 'date' => future_date }) 64 | assert_nil Nesta::Page.find_articles.detect { |a| a == article } 65 | end 66 | end 67 | 68 | it "doesn't return pages without a date" do 69 | with_temp_content_directory do 70 | create(:category) 71 | create(:article) 72 | articles = Nesta::Page.find_articles 73 | assert_equal 1, articles.size 74 | articles.each { |page| fail 'not an article' if page.date.nil? } 75 | end 76 | end 77 | 78 | it 'returns articles in reverse chronological order' do 79 | with_temp_content_directory do 80 | create(:article, metadata: { 'date' => '30 December 2008' }) 81 | create(:article, metadata: { 'date' => '31 December 2008' }) 82 | article1, article2 = Nesta::Page.find_articles[0..1] 83 | assert article1.date > article2.date, 'not reverse chronological order' 84 | end 85 | end 86 | end 87 | 88 | describe '#title' do 89 | it 'returns page heading by default' do 90 | with_temp_content_directory do 91 | page = create(:page, heading: 'Heading') 92 | assert_equal page.heading, Nesta::Page.find_by_path(page.path).title 93 | end 94 | end 95 | 96 | it 'overrides heading with title set in metadata' do 97 | with_temp_content_directory do 98 | page = create(:page, metadata: { 'title' => 'Specific title' }) 99 | assert_equal 'Specific title', Nesta::Page.find_by_path(page.path).title 100 | end 101 | end 102 | 103 | it 'defaults to site title for home page' do 104 | stub_config(temp_content.merge('title' => 'Site title')) do 105 | create(:page, heading: nil, path: 'index') 106 | assert_equal 'Site title', Nesta::Page.find_by_path('/').title 107 | end 108 | end 109 | end 110 | 111 | describe '#heading' do 112 | it 'raises error if heading not set' do 113 | with_temp_content_directory do 114 | assert_raises Nesta::HeadingNotSet do 115 | create(:page, heading: nil).heading 116 | end 117 | end 118 | end 119 | 120 | it 'parses Markdown pages returning contents of first # heading' do 121 | with_temp_content_directory do 122 | page = create(:page) do |path| 123 | file = File.open(path, 'w') 124 | file.write('# Hello Markdown') 125 | file.close 126 | end 127 | assert_equal 'Hello Markdown', page.heading 128 | end 129 | end 130 | 131 | it 'parses Textile pages returning contents of first h1. heading' do 132 | with_temp_content_directory do 133 | page = create(:page, ext: 'textile') do |path| 134 | file = File.open(path, 'w') 135 | file.write('h1. Hello Textile') 136 | file.close 137 | end 138 | assert_equal 'Hello Textile', page.heading 139 | end 140 | end 141 | 142 | it 'parases Haml pages returning contents of first %h1 tag' do 143 | with_temp_content_directory do 144 | page = create(:page, ext: 'haml') do |path| 145 | file = File.open(path, 'w') 146 | file.write('%h1 Hello Haml') 147 | file.close 148 | end 149 | assert_equal 'Hello Haml', page.heading 150 | end 151 | end 152 | 153 | it 'ignores subsequent h1 tags' do 154 | with_temp_content_directory do 155 | page = create(:page, content: '# Second heading') 156 | fail 'wrong h1 tag' if page.heading == 'Second heading' 157 | end 158 | end 159 | 160 | it 'ignores trailing # characters in Markdown headings' do 161 | with_temp_content_directory do 162 | page = create(:page, heading: 'With trailing #') 163 | assert_equal 'With trailing', page.heading 164 | end 165 | end 166 | end 167 | 168 | describe '#abspath' do 169 | it 'returns / for home page' do 170 | with_temp_content_directory do 171 | create(:page, path: 'index') 172 | assert_equal '/', Nesta::Page.find_by_path('index').abspath 173 | end 174 | end 175 | end 176 | 177 | describe '#permalink' do 178 | it 'returns basename of filename' do 179 | with_temp_content_directory do 180 | assert_equal 'page', create(:page, path: 'path/to/page').permalink 181 | end 182 | end 183 | 184 | it 'returns empty string for home page' do 185 | with_temp_content_directory do 186 | home = create(:page, path: 'index') 187 | assert_equal '', Nesta::Page.find_by_path(home.path).permalink 188 | end 189 | end 190 | 191 | it 'removes /index from permalink of index pages' do 192 | with_temp_content_directory do 193 | index = create(:page, path: 'parent/child/index') 194 | assert_equal 'child', index.permalink 195 | end 196 | end 197 | end 198 | 199 | describe '#parent' do 200 | it 'finds the parent by inspecting the path' do 201 | with_temp_content_directory do 202 | parent = create(:page, path: 'parent') 203 | child = create(:page, path: 'parent/child') 204 | assert_equal parent, child.parent 205 | end 206 | end 207 | 208 | it 'returns nil for pages at top level' do 209 | with_temp_content_directory do 210 | assert_nil create(:page, path: 'top-level').parent 211 | end 212 | end 213 | 214 | it 'finds parents that are index pages' do 215 | with_temp_content_directory do 216 | home = create(:page, path: 'index') 217 | child = create(:page, path: 'parent') 218 | assert_equal home, child.parent 219 | end 220 | end 221 | 222 | it "returns grandparent if parent doesn't exist" do 223 | with_temp_content_directory do 224 | grandparent = create(:page, path: 'grandparent') 225 | child = create(:page, path: 'grandparent/parent/child') 226 | assert_equal grandparent, child.parent 227 | end 228 | end 229 | 230 | it 'recognises that home page can be returned as a grandparent' do 231 | with_temp_content_directory do 232 | grandparent = create(:page, path: 'index') 233 | child = create(:page, path: 'parent/child') 234 | assert_equal grandparent, child.parent 235 | end 236 | end 237 | 238 | it 'returns nil if page has no parent' do 239 | with_temp_content_directory do 240 | assert_nil create(:page, path: 'index').parent 241 | end 242 | end 243 | 244 | it 'finds parent of an index page' do 245 | with_temp_content_directory do 246 | parent = create(:page, path: 'parent') 247 | index = create(:page, path: 'parent/child/index') 248 | assert_equal parent, index.parent 249 | end 250 | end 251 | end 252 | 253 | describe '#priority' do 254 | it 'defaults to 0 for pages in category' do 255 | with_temp_content_directory do 256 | page = create(:page, metadata: { 'categories' => 'the-category' }) 257 | assert_equal 0, page.priority('the-category') 258 | assert_nil page.priority('another-category') 259 | end 260 | end 261 | 262 | it 'parses metadata to determine priority in each category' do 263 | with_temp_content_directory do 264 | page = create(:page, metadata: { 265 | 'categories' => ' category-page:1, another-page , and-another :-1 ' 266 | }) 267 | assert_equal 1, page.priority('category-page') 268 | assert_equal 0, page.priority('another-page') 269 | assert_equal -1, page.priority('and-another') 270 | end 271 | end 272 | end 273 | 274 | describe '#pages' do 275 | it 'returns pages but not articles within this category' do 276 | with_temp_content_directory do 277 | category = create(:category) 278 | metadata = { 'categories' => category.path } 279 | page1 = create(:category, metadata: metadata) 280 | page2 = create(:category, metadata: metadata) 281 | create(:article, metadata: metadata) 282 | assert_equal [page1, page2], category.pages 283 | end 284 | end 285 | 286 | it 'sorts pages within a category by priority' do 287 | with_temp_content_directory do 288 | category = create(:category) 289 | create(:category, metadata: { 'categories' => category.path }) 290 | page = create(:category, metadata: { 291 | 'categories' => "#{category.path}:1" 292 | }) 293 | assert_equal 0, category.pages.index(page) 294 | end 295 | end 296 | 297 | it 'orders pages within a category by heading if priority not set' do 298 | with_temp_content_directory do 299 | category = create(:category) 300 | metadata = { 'categories' => category.path } 301 | last = create(:category, heading: 'B', metadata: metadata) 302 | first = create(:category, heading: 'A', metadata: metadata) 303 | assert_equal [first, last], category.pages 304 | end 305 | end 306 | 307 | it 'filters out draft pages when running in production' do 308 | with_temp_content_directory do 309 | category = create(:category) 310 | create(:page, metadata: { 311 | 'categories' => category.path, 312 | 'flags' => 'draft' 313 | }) 314 | fail 'should include draft pages' if category.pages.empty? 315 | Nesta::App.stub(:production?, true) do 316 | assert category.pages.empty?, 'should filter out draft pages' 317 | end 318 | end 319 | end 320 | end 321 | 322 | describe '#articles' do 323 | it "returns just the articles that are in this page's category" do 324 | with_temp_content_directory do 325 | category1 = create(:category) 326 | in_category1 = { 'categories' => category1.path } 327 | category2 = create(:category, metadata: in_category1) 328 | in_category2 = { 'categories' => category2.path } 329 | 330 | article1 = create(:article, metadata: in_category1) 331 | create(:article, metadata: in_category2) 332 | 333 | assert_equal [article1], category1.articles 334 | end 335 | end 336 | 337 | it 'returns articles in reverse chronological order' do 338 | with_temp_content_directory do 339 | category = create(:category) 340 | create(:article, metadata: { 341 | 'date' => '30 December 2008', 342 | 'categories' => category.path 343 | }) 344 | latest = create(:article, metadata: { 345 | 'date' => '31 December 2008', 346 | 'categories' => category.path 347 | }) 348 | assert_equal latest, category.articles.first 349 | end 350 | end 351 | end 352 | 353 | describe '#categories' do 354 | it "returns a page's categories" do 355 | with_temp_content_directory do 356 | category1 = create(:category, path: 'path/to/cat1') 357 | category2 = create(:category, path: 'path/to/cat2') 358 | article = create(:article, metadata: { 359 | 'categories' => 'path/to/cat1, path/to/cat2' 360 | }) 361 | assert_equal [category1, category2], article.categories 362 | end 363 | end 364 | 365 | it 'only returns categories that exist' do 366 | with_temp_content_directory do 367 | article = create(:article, metadata: { 'categories' => 'no-such-page' }) 368 | assert_empty article.categories 369 | end 370 | end 371 | end 372 | 373 | describe '#to_html' do 374 | it 'produces no output if page has no content' do 375 | with_temp_content_directory do 376 | page = create(:page) do |path| 377 | file = File.open(path, 'w') 378 | file.close 379 | end 380 | assert_match /^\s*$/, Nesta::Page.find_by_path(page.path).to_html 381 | end 382 | end 383 | 384 | it 'converts page content to HTML' do 385 | with_temp_content_directory do 386 | assert_match %r{

Hello

}, create(:page, heading: 'Hello').to_html 387 | end 388 | end 389 | 390 | it "doesn't include leading metadata in HTML" do 391 | with_temp_content_directory do 392 | page = create(:page, metadata: { 'key' => 'value' }) 393 | fail 'HTML contains metadata' if page.to_html =~ /(key|value)/ 394 | end 395 | end 396 | end 397 | 398 | describe '#summary' do 399 | it 'returns value set in metadata wrapped in p tags' do 400 | with_temp_content_directory do 401 | page = create(:page, metadata: { 'Summary' => 'Summary paragraph' }) 402 | assert_equal "

Summary paragraph

\n", page.summary 403 | end 404 | end 405 | 406 | it 'treats double newline characters as paragraph breaks' do 407 | with_temp_content_directory do 408 | page = create(:page, metadata: { 'Summary' => 'Line 1\n\nLine 2' }) 409 | assert_includes page.summary, '

Line 1

' 410 | assert_includes page.summary, '

Line 2

' 411 | end 412 | end 413 | end 414 | 415 | describe '#body' do 416 | it "doesn't include page heading" do 417 | with_temp_content_directory do 418 | page = create(:page, heading: 'Heading') 419 | fail 'body contains heading' if page.body_markup =~ /#{page.heading}/ 420 | end 421 | end 422 | end 423 | 424 | describe '#metadata' do 425 | it 'return value of any key set in metadata at top of page' do 426 | with_temp_content_directory do 427 | page = create(:page, metadata: { 'Any key' => 'Any string' }) 428 | assert_equal 'Any string', page.metadata('Any key') 429 | end 430 | end 431 | end 432 | 433 | describe '#layout' do 434 | it 'defaults to :layout' do 435 | with_temp_content_directory do 436 | assert_equal :layout, create(:page).layout 437 | end 438 | end 439 | 440 | it 'returns value set in metadata' do 441 | with_temp_content_directory do 442 | page = create(:page, metadata: { 'Layout' => 'my_layout' }) 443 | assert_equal :my_layout, page.layout 444 | end 445 | end 446 | end 447 | 448 | describe '#template' do 449 | it 'defaults to :page' do 450 | with_temp_content_directory do 451 | assert_equal :page, create(:page).template 452 | end 453 | end 454 | 455 | it 'returns value set in metadata' do 456 | with_temp_content_directory do 457 | page = create(:page, metadata: { 'Template' => 'my_template' }) 458 | assert_equal :my_template, page.template 459 | end 460 | end 461 | end 462 | 463 | describe '#link_text' do 464 | it 'raises error if neither heading nor link text set' do 465 | with_temp_content_directory do 466 | assert_raises Nesta::LinkTextNotSet do 467 | create(:page, heading: nil).link_text 468 | end 469 | end 470 | end 471 | 472 | it 'defaults to page heading' do 473 | with_temp_content_directory do 474 | page = create(:page) 475 | assert_equal page.heading, page.link_text 476 | end 477 | end 478 | 479 | it 'returns value set in metadata' do 480 | with_temp_content_directory do 481 | page = create(:page, metadata: { 'link text' => 'Hello' }) 482 | assert_equal 'Hello', page.link_text 483 | end 484 | end 485 | end 486 | 487 | describe '#read_more' do 488 | it 'has sensible default' do 489 | with_temp_content_directory do 490 | assert_equal 'Continue reading', create(:page).read_more 491 | end 492 | end 493 | 494 | it 'returns value set in metadata' do 495 | with_temp_content_directory do 496 | page = create(:page, metadata: { 'Read more' => 'Click here' }) 497 | assert_equal 'Click here', page.read_more 498 | end 499 | end 500 | end 501 | 502 | describe '#description' do 503 | it 'returns nil by default' do 504 | with_temp_content_directory { assert_nil create(:page).description } 505 | end 506 | 507 | it 'returns value set in metadata' do 508 | with_temp_content_directory do 509 | page = create(:page, metadata: { 'description' => 'A page' }) 510 | assert_equal 'A page', page.description 511 | end 512 | end 513 | end 514 | 515 | describe '#keywords' do 516 | it 'returns nil by default' do 517 | with_temp_content_directory { assert_nil create(:page).keywords } 518 | end 519 | 520 | it 'returns value set in metadata' do 521 | with_temp_content_directory do 522 | page = create(:page, metadata: { 'keywords' => 'good, content' }) 523 | assert_equal 'good, content', page.keywords 524 | end 525 | end 526 | end 527 | 528 | describe '#date' do 529 | it 'returns date article was published' do 530 | with_temp_content_directory do 531 | article = create(:article, metadata: { 'date' => 'November 18 2015' }) 532 | assert_equal '18 November 2015', article.date.strftime('%d %B %Y') 533 | end 534 | end 535 | end 536 | 537 | describe '#flagged_as?' do 538 | it 'returns true if flags metadata contains the string' do 539 | with_temp_content_directory do 540 | page = create(:page, metadata: { 'flags' => 'A flag, popular' }) 541 | assert page.flagged_as?('popular'), 'should be flagged popular' 542 | assert page.flagged_as?('A flag'), 'should be flagged with "A flag"' 543 | fail 'not flagged as green' if page.flagged_as?('green') 544 | end 545 | end 546 | end 547 | 548 | describe '#draft?' do 549 | it 'returns true if page flagged as draft' do 550 | with_temp_content_directory do 551 | page = create(:page, metadata: { 'flags' => 'draft' }) 552 | assert page.draft? 553 | fail 'not a draft' if create(:page).draft? 554 | end 555 | end 556 | end 557 | 558 | describe '#last_modified' do 559 | it 'reads last modified timestamp from disk' do 560 | with_temp_content_directory do 561 | page = create(:page) 562 | file_stat = Minitest::Mock.new 563 | file_stat.expect(:mtime, Time.parse('3 January 2009')) 564 | File.stub(:stat, file_stat) do 565 | assert_equal '03 Jan 2009', page.last_modified.strftime('%d %b %Y') 566 | end 567 | file_stat.verify 568 | end 569 | end 570 | end 571 | end 572 | -------------------------------------------------------------------------------- /test/unit/path_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | 3 | describe Nesta::Path do 4 | def root 5 | '/path/to/site/on/filesystem' 6 | end 7 | 8 | describe '.local' do 9 | it 'returns root path of site on filesystem' do 10 | with_app_root(root) do 11 | assert_equal root, Nesta::Path.local 12 | end 13 | end 14 | 15 | it "should return path for file within site's directory" do 16 | with_app_root(root) do 17 | assert_equal "#{root}/foo/bar", Nesta::Path.local('foo/bar') 18 | end 19 | end 20 | 21 | it 'should combine path components' do 22 | with_app_root(root) do 23 | assert_equal "#{root}/foo/bar", Nesta::Path.local('foo', 'bar') 24 | end 25 | end 26 | end 27 | 28 | describe '.themes' do 29 | it 'should return themes path' do 30 | with_app_root(root) do 31 | assert_equal "#{root}/themes", Nesta::Path.themes 32 | end 33 | end 34 | 35 | it 'should return path for file within themes directory' do 36 | with_app_root(root) do 37 | assert_equal "#{root}/themes/foo/bar", Nesta::Path.themes('foo/bar') 38 | end 39 | end 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /test/unit/plugin_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | 3 | describe Nesta::Plugin do 4 | def remove_plugin_from_list_of_required_files 5 | $LOADED_FEATURES.delete_if do |path| 6 | path =~ /nesta-plugin-test/ 7 | end 8 | end 9 | 10 | def remove_plugin_constants 11 | Nesta::Plugin::Test.send(:remove_const, :VERSION) 12 | rescue NameError 13 | end 14 | 15 | before do 16 | @plugin_lib_path = File.expand_path( 17 | File.join(%w(.. fixtures nesta-plugin-test lib)), File.dirname(__FILE__) 18 | ) 19 | $LOAD_PATH.unshift(@plugin_lib_path) 20 | Nesta::Plugin.loaded.clear 21 | end 22 | 23 | after do 24 | $LOAD_PATH.shift if $LOAD_PATH[0] == @plugin_lib_path 25 | remove_plugin_from_list_of_required_files 26 | remove_plugin_constants 27 | end 28 | 29 | it 'must be required in order to load' do 30 | assert Nesta::Plugin.loaded.empty?, 'should be empty' 31 | end 32 | 33 | it 'records loaded plugins' do 34 | require 'nesta-plugin-test' 35 | assert_equal ['nesta-plugin-test'], Nesta::Plugin.loaded 36 | end 37 | 38 | it 'loads the plugin module' do 39 | require 'nesta-plugin-test' 40 | Nesta::Plugin::Test::VERSION 41 | end 42 | 43 | it 'initializes the plugin' do 44 | require 'nesta-plugin-test' 45 | Nesta::Plugin.initialize_plugins 46 | assert Nesta::Page.respond_to?(:method_added_by_plugin), 'not initialized' 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /test/unit/static/assets_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | 3 | require_relative '../../../lib/nesta/static/assets' 4 | 5 | describe 'Assets' do 6 | include TestConfiguration 7 | 8 | after do 9 | remove_temp_directory 10 | end 11 | 12 | it 'is happy if attachments directory not present' do 13 | in_temporary_project do |project_root| 14 | stub_config('content' => File.join(project_root, 'content')) do 15 | Nesta::Static::Assets.new('dist').copy_attachments 16 | end 17 | end 18 | end 19 | 20 | it 'copies attachments into build directory' do 21 | build_dir = 'dist' 22 | attachment = 'image.jpeg' 23 | 24 | in_temporary_project do |project_root| 25 | stub_config('content' => File.join(project_root, 'content')) do 26 | FileUtils.mkdir_p(Nesta::Config.attachment_path) 27 | File.open(File.join(Nesta::Config.attachment_path, attachment), 'w') 28 | 29 | Nesta::Static::Assets.new(build_dir).copy_attachments 30 | 31 | assert_exists_in_project File.join(build_dir, 'attachments', attachment) 32 | end 33 | end 34 | end 35 | 36 | it 'is happy if public directory not present' do 37 | in_temporary_project do 38 | Nesta::Static::Assets.new('dist').copy_public_folder 39 | end 40 | end 41 | 42 | it 'copies contents of public directory into build directory' do 43 | build_dir = 'dist' 44 | asset_path = File.join('css', 'third-party.css') 45 | 46 | in_temporary_project do |project_root| 47 | public_path = File.join(project_root, 'public') 48 | FileUtils.mkdir_p(File.dirname(File.join(public_path, asset_path))) 49 | File.open(File.join(public_path, asset_path), 'w') 50 | 51 | Nesta::Static::Assets.new(build_dir).copy_public_folder 52 | 53 | assert_exists_in_project File.join(build_dir, asset_path) 54 | end 55 | end 56 | end 57 | -------------------------------------------------------------------------------- /test/unit/static/html_file_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | 3 | require_relative '../../../lib/nesta/static/html_file' 4 | 5 | describe 'HtmlFile' do 6 | include ModelFactory 7 | include TestConfiguration 8 | 9 | after do 10 | remove_temp_directory 11 | end 12 | 13 | it 'determines HTML filename from build dir and page filename' do 14 | build_dir = 'dist' 15 | 16 | with_temp_content_directory do 17 | page = create(:page) 18 | 19 | html_file = Nesta::Static::HtmlFile.new(build_dir, page) 20 | 21 | expected = File.join(build_dir, 'page-1.html') 22 | assert_equal expected, html_file.filename 23 | end 24 | end 25 | 26 | it 'creates index.html in directory if page shares path with directory' do 27 | build_dir = 'dist' 28 | 29 | with_temp_content_directory do 30 | page = create(:page) 31 | ext = File.extname(page.filename) 32 | directory_path = page.filename.sub(/#{ext}$/, '') 33 | FileUtils.mkdir(directory_path) 34 | 35 | html_file = Nesta::Static::HtmlFile.new(build_dir, page) 36 | 37 | expected = File.join(build_dir, 'page-1', 'index.html') 38 | assert_equal expected, html_file.filename 39 | end 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /test/unit/static/site_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | 3 | require_relative '../../../lib/nesta/static/site' 4 | 5 | describe 'Site' do 6 | include ModelFactory 7 | include TestConfiguration 8 | 9 | after do 10 | remove_temp_directory 11 | end 12 | 13 | it 'converts Markdown to HTML' do 14 | build_dir = 'dist' 15 | domain = 'localhost' 16 | 17 | in_temporary_project do 18 | with_temp_content_directory do 19 | page = create(:page) 20 | 21 | Nesta::Static::Site.new(build_dir, domain).render_pages 22 | 23 | html_file = File.join(build_dir, page.abspath + '.html') 24 | markup = File.open(html_file).read 25 | 26 | assert markup.include?("#{page.title}") 27 | end 28 | end 29 | end 30 | 31 | it 'renders a 404 not found page' do 32 | build_dir = 'dist' 33 | domain = 'localhost' 34 | 35 | in_temporary_project do 36 | with_temp_content_directory do 37 | Nesta::Static::Site.new(build_dir, domain).render_not_found 38 | 39 | html_file = File.join(build_dir, '404.html') 40 | markup = File.open(html_file).read 41 | 42 | assert markup.include?('

Page not found

') 43 | end 44 | end 45 | end 46 | 47 | it 'renders Atom feed' do 48 | build_dir = 'dist' 49 | domain = 'mysite.com' 50 | 51 | in_temporary_project do 52 | with_temp_content_directory do 53 | article = create(:article) 54 | Nesta::Static::Site.new(build_dir, domain).render_atom_feed 55 | 56 | xml_file = File.join(build_dir, 'articles.xml') 57 | xml = File.open(xml_file).read 58 | 59 | assert xml.include?(" { 'templated_assets' => [css_path] }) do 87 | views = File.join(project_root, 'views') 88 | FileUtils.mkdir_p(views) 89 | File.open(File.join(views, 'styles.sass'), 'w') do |sass| 90 | sass.write("p\n font-size: 1em\n") 91 | end 92 | 93 | site = Nesta::Static::Site.new(build_dir, 'mysite.com') 94 | site.render_templated_assets 95 | 96 | css_file = File.join(build_dir, css_path) 97 | assert_exists_in_project(css_file) 98 | 99 | assert_equal File.open(css_file).read, "p {\n font-size: 1em;\n}" 100 | end 101 | end 102 | end 103 | end 104 | -------------------------------------------------------------------------------- /test/unit/system_command_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | require_relative '../../lib/nesta/system_command' 3 | 4 | describe 'Nesta::SystemCommand' do 5 | describe '#run' do 6 | it 'catches errors when running external processes' do 7 | command = Nesta::SystemCommand.new 8 | command.run('ls / >/dev/null') 9 | begin 10 | stderr, $stderr = $stderr, StringIO.new 11 | assert_raises(SystemExit) do 12 | command.run('ls no-such-file 2>/dev/null') 13 | end 14 | ensure 15 | $stderr.close 16 | $stderr = stderr 17 | end 18 | end 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /views/analytics.haml: -------------------------------------------------------------------------------- 1 | - if @google_analytics_code 2 | :plain 3 | 4 | 5 | 12 | -------------------------------------------------------------------------------- /views/atom.haml: -------------------------------------------------------------------------------- 1 | !!! XML 2 | %feed(xmlns='https://www.w3.org/2005/Atom') 3 | %title(type='text')= @title 4 | %generator(uri='https://nestacms.com') Nesta 5 | %id= atom_id 6 | %link(href="#{path_to('/articles.xml', :uri => true)}" rel='self') 7 | %link(href="#{path_to('/', :uri => true)}" rel='alternate') 8 | %subtitle(type='text')= @subtitle 9 | - if @articles[0] 10 | %updated= @articles[0].date(:xmlschema) 11 | - if @author 12 | %author 13 | - if @author['name'] 14 | %name= @author['name'] 15 | - if @author['uri'] 16 | %uri= @author['uri'] 17 | - if @author['email'] 18 | %email= @author['email'] 19 | - @articles.each do |article| 20 | %entry 21 | %title= article.heading 22 | %link{ :href => path_to(article.path, :uri => true), :type => 'text/html', :rel => 'alternate' } 23 | %id= atom_id(article) 24 | %content(type='html')&= find_and_preserve(absolute_urls(article.body(self))) 25 | %published= article.date(:xmlschema) 26 | %updated= article.date(:xmlschema) 27 | - article.categories.each do |category| 28 | %category{ :term => category.permalink } 29 | -------------------------------------------------------------------------------- /views/categories.haml: -------------------------------------------------------------------------------- 1 | - if ! @menu_items.empty? 2 | %nav.categories 3 | %h1 Articles by category 4 | - display_menu(@menu_items, :class => "menu") 5 | -------------------------------------------------------------------------------- /views/colors.sass: -------------------------------------------------------------------------------- 1 | $base-color: #262631 2 | $background-color: #fff 3 | $tint: #EAF4F7 4 | $border-color: #71ADCE 5 | $link-color: #006DD1 6 | $visited-link-color: darken($link-color, 5%) 7 | $hover-link-color: lighten($link-color, 15%) 8 | $active-link-color: darken($link-color, 20%) 9 | $nav-link-color: desaturate(lighten($link-color, 25%), 35%) 10 | $meta-color: #87877D 11 | -------------------------------------------------------------------------------- /views/comments.haml: -------------------------------------------------------------------------------- 1 | - if @page.receives_comments? && short_name = Nesta::Config.disqus_short_name 2 | #disqus_thread 3 | - if Sinatra::Application.environment == :development 4 | :javascript 5 | var disqus_developer = true; 6 | %script{:type => 'text/javascript', :src => "https://#{short_name}.disqus.com/embed.js", :async => true} 7 | 8 | %noscript 9 | %a(href="https://#{short_name}.disqus.com/embed.js?url=ref") View comments. 10 | -------------------------------------------------------------------------------- /views/error.haml: -------------------------------------------------------------------------------- 1 | %nav.breadcrumb 2 | %a(href="#{path_to('/')}") Home 3 | 4 | #content 5 | %section(role="main") 6 | %h1 Sorry, something went wrong 7 | 8 | %p 9 | An error occurred whilst we were trying to serve your page. Please 10 | bear with us, or try another page. 11 | 12 | = haml :sidebar, :layout => false 13 | -------------------------------------------------------------------------------- /views/feed.haml: -------------------------------------------------------------------------------- 1 | - title ||= "Atom feed" 2 | .feed 3 | %a(href="#{path_to('/articles.xml')}" title=title)= title 4 | -------------------------------------------------------------------------------- /views/footer.haml: -------------------------------------------------------------------------------- 1 | %footer.branding 2 | %p 3 | Powered by Nesta, a 4 | = succeed "." do 5 | %a(href="https://nestacms.com") Ruby CMS 6 | -------------------------------------------------------------------------------- /views/header.haml: -------------------------------------------------------------------------------- 1 | %header(role="banner") 2 | %h1= @heading 3 | %h2= @subtitle 4 | -------------------------------------------------------------------------------- /views/layout.haml: -------------------------------------------------------------------------------- 1 | 2 | %html(lang="en") 3 | %head 4 | %meta(charset="utf-8") 5 | %meta(content="width=device-width, initial-scale=1.0" name="viewport") 6 | - if @description 7 | %meta(name="description" content=@description) 8 | - if @keywords 9 | %meta(name="keywords" content=@keywords) 10 | %title= @title 11 | %link(href='//fonts.googleapis.com/css?family=Roboto+Slab:400,700' rel='stylesheet' type='text/css') 12 | 13 | %link(href="#{path_to('/css/master.css')}" media="screen" rel="stylesheet") 14 | - local_stylesheet_link_tag('local') 15 | 16 | /[if lte IE 6] 17 | %link(rel="stylesheet" href="//universal-ie6-css.googlecode.com/files/ie6.1.1.css" media="screen, projection") 18 | /[if lt IE 9] 19 | %script(src="//html5shim.googlecode.com/svn/trunk/html5.js") 20 | %script(src="//cdnjs.cloudflare.com/ajax/libs/respond.js/1.1.0/respond.min.js") 21 | %link(href="#{path_to('/articles.xml')}" rel="alternate" type="application/atom+xml") 22 | = haml :analytics, :layout => false 23 | %body 24 | #container 25 | = haml :header, :layout => false 26 | = yield 27 | = haml :footer, :layout => false 28 | -------------------------------------------------------------------------------- /views/master.sass: -------------------------------------------------------------------------------- 1 | @use "sass:color" 2 | 3 | @use "mixins" as * 4 | @use "normalize" 5 | 6 | body 7 | font: $size1 "Roboto Slab", serif 8 | line-height: 1.6 9 | color: $base-color 10 | 11 | div#container 12 | position: relative 13 | margin: 0 1em 14 | 15 | header[role=banner] 16 | h1, 17 | h2 18 | margin: 0 19 | font-weight: normal 20 | 21 | h1 22 | margin-top: 0.5em 23 | font-size: $size2 24 | 25 | h2 26 | color: $meta-color 27 | font-size: $size0 28 | 29 | footer.branding 30 | padding-top: 2em 31 | 32 | +respond-to(medium-screens) 33 | div#container 34 | margin: 0 auto 35 | max-width: $measure 36 | 37 | +respond-to(wide-screens) 38 | div#container 39 | max-width: $full-page-width 40 | 41 | div#content 42 | float: left 43 | width: 37em 44 | 45 | div#sidebar 46 | float: right 47 | width: $sidebar-width 48 | 49 | footer.branding 50 | clear: both 51 | 52 | h1, h2, h3, h4 53 | margin: 1em 0 0.5em 54 | 55 | h1 56 | font-size: $size4 57 | h2 58 | font-size: $size3 59 | h3 60 | font-size: $size2 61 | h4 62 | font-size: $size2 63 | 64 | ol, 65 | p, 66 | pre, 67 | ul 68 | margin: 0 0 $vertical-margin 69 | 70 | li 71 | margin: 0 72 | 73 | blockquote 74 | margin: $vertical-margin 0 75 | padding: 0 calc($vertical-margin / 2) 76 | border-left: 5px solid $tint 77 | 78 | font-style: italic 79 | color: color.adjust($base-color, $red: 55, $green: 55, $blue: 55) 80 | 81 | pre 82 | display: block 83 | width: auto 84 | 85 | padding: 0.5em 86 | 87 | font-size: $size0 88 | 89 | overflow: auto 90 | white-space: pre 91 | 92 | code 93 | font-size: 1em 94 | 95 | code 96 | font-size: $size0 97 | 98 | img 99 | border: none 100 | 101 | nav.breadcrumb 102 | margin-top: $vertical-margin 103 | color: $meta-color 104 | 105 | font-size: 0.909em 106 | 107 | ul 108 | margin: 0 109 | padding: 0 110 | 111 | li 112 | display: inline 113 | list-style: none 114 | 115 | &::before 116 | content: " > " 117 | 118 | &:last-child 119 | display: block 120 | margin-left: 1em 121 | 122 | +respond-to(medium-screens) 123 | display: inline 124 | margin: 0 125 | 126 | &:first-child 127 | margin-left: 0 128 | 129 | a 130 | color: $link-color 131 | +transition(color 0.25s 0 ease) 132 | &:visited 133 | color: $visited-link-color 134 | &:hover 135 | color: $hover-link-color 136 | &:active 137 | color: $active-link-color 138 | 139 | nav.breadcrumb, 140 | nav.categories, 141 | div.feed, 142 | article p.meta 143 | a 144 | color: $nav-link-color 145 | &:hover a 146 | color: $link-color 147 | a:hover 148 | color: $hover-link-color 149 | 150 | article 151 | img 152 | max-width: 100% 153 | 154 | footer 155 | p.meta 156 | font-style: italic 157 | color: $meta-color 158 | 159 | // Pages of content 160 | article[role="main"] 161 | & > h1 162 | font-weight: normal 163 | 164 | div#disqus_thread 165 | img 166 | max-width: none 167 | ul#dsq-comments 168 | margin-left: 0 169 | 170 | // Pages/articles assigned to this page 171 | section.pages, 172 | section.articles 173 | & > ol 174 | margin-left: 0 175 | padding-left: 0 176 | li 177 | position: relative 178 | list-style: none 179 | 180 | article 181 | h1 182 | margin-bottom: 0.2em 183 | 184 | font-size: $size3 185 | font-weight: normal 186 | ol li 187 | list-style: decimal 188 | ul li 189 | list-style: disc 190 | 191 | header h1 192 | font-size: $size3 193 | 194 | article 195 | h1 196 | a 197 | text-decoration: none 198 | p.read_more 199 | font-size: $size1 200 | margin: -1em 0 0 0 201 | footer 202 | border-top: none 203 | 204 | font-size: $size0 205 | 206 | section.articles 207 | border-top: 2px solid $tint 208 | 209 | &:first-child 210 | border-top: none 211 | 212 | & > header h1 213 | color: $meta-color 214 | font-weight: normal 215 | 216 | div#sidebar 217 | border-top: 2px solid $tint 218 | 219 | h1 220 | font-size: $size3 221 | 222 | ol, ul 223 | padding-left: 0 224 | list-style: none 225 | 226 | li 227 | display: inline 228 | 229 | &::after 230 | content: ' / ' 231 | &:last-child 232 | &::after 233 | content: '' 234 | 235 | +respond-to(medium-screens) 236 | margin-top: 1.4em 237 | 238 | p 239 | font-size: $size0 240 | 241 | +respond-to(wide-screens) 242 | border-top: none 243 | 244 | color: $meta-color 245 | 246 | h1 247 | font-size: $size2 248 | font-weight: normal 249 | 250 | ol, ul 251 | li 252 | display: list-item 253 | 254 | &::after 255 | content: '' 256 | 257 | div.feed 258 | margin: $vertical-margin 0 259 | -------------------------------------------------------------------------------- /views/mixins.sass: -------------------------------------------------------------------------------- 1 | // Default ratios between font sizes; used to maintain the type hierarchy. 2 | // https://www.markboulton.co.uk/journal/comments/five-simple-steps-to-better-typography-part-4 3 | 4 | $size5: 2.18em 5 | $size4: 1.64em 6 | $size3: 1.45em 7 | $size2: 1.18em 8 | $size1: 1em 9 | $size0: 0.88em 10 | 11 | $vertical-margin: 1.5em 12 | 13 | // Colour settings 14 | 15 | @use "sass:color" 16 | 17 | $base-color: #313126 18 | $tint: #EEEEC6 19 | $border-color: #D7C28B 20 | $link-color: #4786fd 21 | $visited-link-color: color.adjust($link-color, $lightness: -5%) 22 | $hover-link-color: color.adjust($link-color, $lightness: -15%) 23 | $active-link-color: color.adjust($link-color, $lightness: -20%) 24 | $nav-link-color: color.scale(color.adjust($link-color, $lightness: 10%), $saturation: -25%) 25 | $meta-color: #87877D 26 | 27 | @function empx($em, $base: 16px) 28 | @return calc($em / 1em) * $base 29 | 30 | // Layout settings 31 | 32 | $measure: empx(36em) 33 | $gutter: empx(6em) 34 | $sidebar-width: empx(14em) 35 | $full-page-width: $measure + $gutter + $sidebar-width 36 | 37 | $max-handheld-width: 400px 38 | $min-medium-width: $measure + empx(1em) 39 | $min-wide-width: $full-page-width + empx(4em) 40 | 41 | // Mixin 42 | 43 | =border-radius($radius) 44 | -webkit-border-radius: $radius 45 | -moz-border-radius: $radius 46 | border-radius: $radius 47 | 48 | =transition($definition) 49 | -moz-transition: $definition 50 | -o-transition: $definition 51 | -webkit-transition: $definition 52 | transition: $definition 53 | 54 | =respond-to($media) 55 | @if $media == handhelds 56 | @media only screen and (max-width: $max-handheld-width) 57 | @content 58 | @else if $media == medium-screens 59 | @media only screen and (min-width: $min-medium-width) 60 | @content 61 | @else if $media == only-medium-screens 62 | @media only screen and (min-width: $min-medium-width) and (max-width: $min-wide-width - 1px) 63 | @content 64 | @else if $media == wide-screens 65 | @media only screen and (min-width: $min-wide-width) 66 | @content 67 | -------------------------------------------------------------------------------- /views/normalize.scss: -------------------------------------------------------------------------------- 1 | /*! normalize.css v2.1.0 | MIT License | git.io/normalize */ 2 | 3 | /* ========================================================================== 4 | HTML5 display definitions 5 | ========================================================================== */ 6 | 7 | /** 8 | * Correct `block` display not defined in IE 8/9. 9 | */ 10 | 11 | article, 12 | aside, 13 | details, 14 | figcaption, 15 | figure, 16 | footer, 17 | header, 18 | main, 19 | nav, 20 | section, 21 | summary { 22 | display: block; 23 | } 24 | 25 | /** 26 | * Correct `inline-block` display not defined in IE 8/9. 27 | */ 28 | 29 | audio, 30 | canvas, 31 | video { 32 | display: inline-block; 33 | } 34 | 35 | /** 36 | * Prevent modern browsers from displaying `audio` without controls. 37 | * Remove excess height in iOS 5 devices. 38 | */ 39 | 40 | audio:not([controls]) { 41 | display: none; 42 | height: 0; 43 | } 44 | 45 | /** 46 | * Address styling not present in IE 8/9. 47 | */ 48 | 49 | [hidden] { 50 | display: none; 51 | } 52 | 53 | /* ========================================================================== 54 | Base 55 | ========================================================================== */ 56 | 57 | /** 58 | * 1. Set default font family to sans-serif. 59 | * 2. Prevent iOS text size adjust after orientation change, without disabling 60 | * user zoom. 61 | */ 62 | 63 | html { 64 | font-family: sans-serif; /* 1 */ 65 | -webkit-text-size-adjust: 100%; /* 2 */ 66 | -ms-text-size-adjust: 100%; /* 2 */ 67 | } 68 | 69 | /** 70 | * Remove default margin. 71 | */ 72 | 73 | body { 74 | margin: 0; 75 | } 76 | 77 | /* ========================================================================== 78 | Links 79 | ========================================================================== */ 80 | 81 | /** 82 | * Address `outline` inconsistency between Chrome and other browsers. 83 | */ 84 | 85 | a:focus { 86 | outline: thin dotted; 87 | } 88 | 89 | /** 90 | * Improve readability when focused and also mouse hovered in all browsers. 91 | */ 92 | 93 | a:active, 94 | a:hover { 95 | outline: 0; 96 | } 97 | 98 | /* ========================================================================== 99 | Typography 100 | ========================================================================== */ 101 | 102 | /** 103 | * Address variable `h1` font-size and margin within `section` and `article` 104 | * contexts in Firefox 4+, Safari 5, and Chrome. 105 | */ 106 | 107 | h1 { 108 | font-size: 2em; 109 | margin: 0.67em 0; 110 | } 111 | 112 | /** 113 | * Address styling not present in IE 8/9, Safari 5, and Chrome. 114 | */ 115 | 116 | abbr[title] { 117 | border-bottom: 1px dotted; 118 | } 119 | 120 | /** 121 | * Address style set to `bolder` in Firefox 4+, Safari 5, and Chrome. 122 | */ 123 | 124 | b, 125 | strong { 126 | font-weight: bold; 127 | } 128 | 129 | /** 130 | * Address styling not present in Safari 5 and Chrome. 131 | */ 132 | 133 | dfn { 134 | font-style: italic; 135 | } 136 | 137 | /** 138 | * Address differences between Firefox and other browsers. 139 | */ 140 | 141 | hr { 142 | -moz-box-sizing: content-box; 143 | box-sizing: content-box; 144 | height: 0; 145 | } 146 | 147 | /** 148 | * Address styling not present in IE 8/9. 149 | */ 150 | 151 | mark { 152 | background: #ff0; 153 | color: #000; 154 | } 155 | 156 | /** 157 | * Correct font family set oddly in Safari 5 and Chrome. 158 | */ 159 | 160 | code, 161 | kbd, 162 | pre, 163 | samp { 164 | font-family: monospace, serif; 165 | font-size: 1em; 166 | } 167 | 168 | /** 169 | * Improve readability of pre-formatted text in all browsers. 170 | */ 171 | 172 | pre { 173 | white-space: pre-wrap; 174 | } 175 | 176 | /** 177 | * Set consistent quote types. 178 | */ 179 | 180 | q { 181 | quotes: "\201C" "\201D" "\2018" "\2019"; 182 | } 183 | 184 | /** 185 | * Address inconsistent and variable font size in all browsers. 186 | */ 187 | 188 | small { 189 | font-size: 80%; 190 | } 191 | 192 | /** 193 | * Prevent `sub` and `sup` affecting `line-height` in all browsers. 194 | */ 195 | 196 | sub, 197 | sup { 198 | font-size: 75%; 199 | line-height: 0; 200 | position: relative; 201 | vertical-align: baseline; 202 | } 203 | 204 | sup { 205 | top: -0.5em; 206 | } 207 | 208 | sub { 209 | bottom: -0.25em; 210 | } 211 | 212 | /* ========================================================================== 213 | Embedded content 214 | ========================================================================== */ 215 | 216 | /** 217 | * Remove border when inside `a` element in IE 8/9. 218 | */ 219 | 220 | img { 221 | border: 0; 222 | } 223 | 224 | /** 225 | * Correct overflow displayed oddly in IE 9. 226 | */ 227 | 228 | svg:not(:root) { 229 | overflow: hidden; 230 | } 231 | 232 | /* ========================================================================== 233 | Figures 234 | ========================================================================== */ 235 | 236 | /** 237 | * Address margin not present in IE 8/9 and Safari 5. 238 | */ 239 | 240 | figure { 241 | margin: 0; 242 | } 243 | 244 | /* ========================================================================== 245 | Forms 246 | ========================================================================== */ 247 | 248 | /** 249 | * Define consistent border, margin, and padding. 250 | */ 251 | 252 | fieldset { 253 | border: 1px solid #c0c0c0; 254 | margin: 0 2px; 255 | padding: 0.35em 0.625em 0.75em; 256 | } 257 | 258 | /** 259 | * 1. Correct `color` not being inherited in IE 8/9. 260 | * 2. Remove padding so people aren't caught out if they zero out fieldsets. 261 | */ 262 | 263 | legend { 264 | border: 0; /* 1 */ 265 | padding: 0; /* 2 */ 266 | } 267 | 268 | /** 269 | * 1. Correct font family not being inherited in all browsers. 270 | * 2. Correct font size not being inherited in all browsers. 271 | * 3. Address margins set differently in Firefox 4+, Safari 5, and Chrome. 272 | */ 273 | 274 | button, 275 | input, 276 | select, 277 | textarea { 278 | font-family: inherit; /* 1 */ 279 | font-size: 100%; /* 2 */ 280 | margin: 0; /* 3 */ 281 | } 282 | 283 | /** 284 | * Address Firefox 4+ setting `line-height` on `input` using `!important` in 285 | * the UA stylesheet. 286 | */ 287 | 288 | button, 289 | input { 290 | line-height: normal; 291 | } 292 | 293 | /** 294 | * Address inconsistent `text-transform` inheritance for `button` and `select`. 295 | * All other form control elements do not inherit `text-transform` values. 296 | * Correct `button` style inheritance in Chrome, Safari 5+, and IE 8+. 297 | * Correct `select` style inheritance in Firefox 4+ and Opera. 298 | */ 299 | 300 | button, 301 | select { 302 | text-transform: none; 303 | } 304 | 305 | /** 306 | * 1. Avoid the WebKit bug in Android 4.0.* where (2) destroys native `audio` 307 | * and `video` controls. 308 | * 2. Correct inability to style clickable `input` types in iOS. 309 | * 3. Improve usability and consistency of cursor style between image-type 310 | * `input` and others. 311 | */ 312 | 313 | button, 314 | html input[type="button"], /* 1 */ 315 | input[type="reset"], 316 | input[type="submit"] { 317 | -webkit-appearance: button; /* 2 */ 318 | cursor: pointer; /* 3 */ 319 | } 320 | 321 | /** 322 | * Re-set default cursor for disabled elements. 323 | */ 324 | 325 | button[disabled], 326 | html input[disabled] { 327 | cursor: default; 328 | } 329 | 330 | /** 331 | * 1. Address box sizing set to `content-box` in IE 8/9. 332 | * 2. Remove excess padding in IE 8/9. 333 | */ 334 | 335 | input[type="checkbox"], 336 | input[type="radio"] { 337 | box-sizing: border-box; /* 1 */ 338 | padding: 0; /* 2 */ 339 | } 340 | 341 | /** 342 | * 1. Address `appearance` set to `searchfield` in Safari 5 and Chrome. 343 | * 2. Address `box-sizing` set to `border-box` in Safari 5 and Chrome 344 | * (include `-moz` to future-proof). 345 | */ 346 | 347 | input[type="search"] { 348 | -webkit-appearance: textfield; /* 1 */ 349 | -moz-box-sizing: content-box; 350 | -webkit-box-sizing: content-box; /* 2 */ 351 | box-sizing: content-box; 352 | } 353 | 354 | /** 355 | * Remove inner padding and search cancel button in Safari 5 and Chrome 356 | * on OS X. 357 | */ 358 | 359 | input[type="search"]::-webkit-search-cancel-button, 360 | input[type="search"]::-webkit-search-decoration { 361 | -webkit-appearance: none; 362 | } 363 | 364 | /** 365 | * Remove inner padding and border in Firefox 4+. 366 | */ 367 | 368 | button::-moz-focus-inner, 369 | input::-moz-focus-inner { 370 | border: 0; 371 | padding: 0; 372 | } 373 | 374 | /** 375 | * 1. Remove default vertical scrollbar in IE 8/9. 376 | * 2. Improve readability and alignment in all browsers. 377 | */ 378 | 379 | textarea { 380 | overflow: auto; /* 1 */ 381 | vertical-align: top; /* 2 */ 382 | } 383 | 384 | /* ========================================================================== 385 | Tables 386 | ========================================================================== */ 387 | 388 | /** 389 | * Remove most spacing between table cells. 390 | */ 391 | 392 | table { 393 | border-collapse: collapse; 394 | border-spacing: 0; 395 | } 396 | -------------------------------------------------------------------------------- /views/not_found.haml: -------------------------------------------------------------------------------- 1 | %nav.breadcrumb 2 | %a(href="/") Home 3 | 4 | #content 5 | %section(role="main") 6 | %h1 Page not found 7 | 8 | %p 9 | Please try another page or, if you think something is wrong with 10 | our site, do get in touch and let us know. 11 | 12 | = haml :sidebar, :layout => false 13 | -------------------------------------------------------------------------------- /views/page.haml: -------------------------------------------------------------------------------- 1 | %nav.breadcrumb 2 | - display_breadcrumbs 3 | 4 | #content 5 | %article(role="main") 6 | ~ @page.to_html(self) 7 | 8 | %section.pages 9 | = article_summaries(@page.pages) 10 | 11 | - unless @page.articles.empty? 12 | %section.articles 13 | %header 14 | %h1= articles_heading 15 | = article_summaries(@page.articles) 16 | 17 | = haml :page_meta, :layout => false, :locals => { :page => @page } 18 | 19 | = haml :comments, :layout => false 20 | 21 | = haml :sidebar, :layout => false 22 | -------------------------------------------------------------------------------- /views/page_meta.haml: -------------------------------------------------------------------------------- 1 | - if page.date 2 | %footer 3 | %p.meta 4 | Published 5 | - if page.date 6 | on 7 | %time(datetime="#{page.date.to_s}" pubdate=true)= format_date(page.date) 8 | - if (! page.categories.empty?) 9 | in 10 | - page.categories.each do |category| 11 | - if category != page.categories[-1] 12 | = succeed ',' do 13 | %a(href="#{path_to(category.abspath)}")= category.link_text 14 | - else 15 | %a(href="#{path_to(category.abspath)}")= category.link_text 16 | 17 | -------------------------------------------------------------------------------- /views/sidebar.haml: -------------------------------------------------------------------------------- 1 | #sidebar 2 | = haml :categories, :layout => false 3 | = haml :feed, :layout => false 4 | -------------------------------------------------------------------------------- /views/sitemap.haml: -------------------------------------------------------------------------------- 1 | !!! XML 2 | %urlset(xmlns="https://www.sitemaps.org/schemas/sitemap/0.9") 3 | %url 4 | %loc= path_to('/', :uri => true) 5 | %changefreq daily 6 | %priority 1.0 7 | %lastmod= @last.xmlschema 8 | - @pages.each do |page| 9 | %url 10 | %loc= path_to(page.path, :uri => true) 11 | %lastmod= page.last_modified.xmlschema 12 | -------------------------------------------------------------------------------- /views/summaries.haml: -------------------------------------------------------------------------------- 1 | - unless pages.empty? 2 | %ol 3 | - pages.each do |page| 4 | %li 5 | %article 6 | %header 7 | %h1 8 | %a(href="#{path_to(page.abspath)}")= page.link_text 9 | - if page.summary.nil? || page.summary.empty? 10 | ~ page.body(self) 11 | - else 12 | ~ page.summary 13 | %p.read_more 14 | %a(href="#{path_to(page.abspath)}")= page.read_more 15 | = haml :page_meta, :layout => false, :locals => { :page => page } 16 | --------------------------------------------------------------------------------