├── .github └── workflows │ └── ci.yml ├── .gitignore ├── .rspec ├── CHANGELOG.md ├── Gemfile ├── Gemfile.lock ├── MIT-LICENSE ├── README.md ├── Rakefile ├── app ├── assets │ ├── config │ │ └── dokno_manifest.js │ ├── images │ │ └── dokno │ │ │ └── .keep │ ├── javascripts │ │ ├── dokno.js │ │ ├── dokno │ │ │ └── application.js │ │ ├── feather.min.js │ │ └── init.js │ └── stylesheets │ │ └── dokno │ │ ├── application.css │ │ └── tailwind.min.css ├── controllers │ └── dokno │ │ ├── application_controller.rb │ │ ├── articles_controller.rb │ │ ├── categories_controller.rb │ │ ├── pagination_concern.rb │ │ └── user_concern.rb ├── helpers │ └── dokno │ │ └── application_helper.rb ├── models │ └── dokno │ │ ├── application_record.rb │ │ ├── article.rb │ │ ├── article_slug.rb │ │ ├── category.rb │ │ └── log.rb └── views │ ├── dokno │ ├── _article_formatting.html.erb │ ├── _article_panel.html.erb │ ├── _panel_formatting.html.erb │ ├── _reset_formatting.html.erb │ ├── articles │ │ ├── _article_form.html.erb │ │ ├── edit.html.erb │ │ ├── new.html.erb │ │ └── show.html.erb │ └── categories │ │ ├── _category_form.html.erb │ │ ├── edit.html.erb │ │ ├── index.html.erb │ │ └── new.html.erb │ ├── layouts │ └── dokno │ │ └── application.html.erb │ └── partials │ ├── _category_header.html.erb │ ├── _form_errors.html.erb │ ├── _logs.html.erb │ └── _pagination.html.erb ├── bin └── rails ├── config └── routes.rb ├── db └── migrate │ ├── 20201203190330_baseline.rb │ ├── 20201211192306_add_review_due_at_to_articles.rb │ └── 20201213165700_add_starred_to_article.rb ├── dokno.gemspec ├── lib ├── dokno.rb ├── dokno │ ├── config │ │ └── config.rb │ ├── engine.rb │ └── version.rb ├── generators │ └── dokno │ │ ├── install │ │ └── install_generator.rb │ │ └── templates │ │ └── config │ │ ├── dokno_template.md │ │ └── initializers │ │ └── dokno.rb └── tasks │ └── dokno_tasks.rake └── spec ├── dummy ├── .ruby-version ├── Rakefile ├── app │ ├── assets │ │ ├── config │ │ │ └── manifest.js │ │ ├── images │ │ │ └── .keep │ │ └── stylesheets │ │ │ └── application.css │ ├── controllers │ │ ├── application_controller.rb │ │ ├── concerns │ │ │ └── .keep │ │ └── dummy_controller.rb │ ├── helpers │ │ └── application_helper.rb │ ├── lib │ │ └── user.rb │ ├── models │ │ ├── application_record.rb │ │ └── concerns │ │ │ └── .keep │ └── views │ │ ├── dummy │ │ └── dummy.html.erb │ │ └── layouts │ │ └── application.html.erb ├── bin │ ├── rails │ ├── rake │ └── setup ├── config.ru ├── config │ ├── application.rb │ ├── boot.rb │ ├── database.yml │ ├── dokno_template.md │ ├── environment.rb │ ├── environments │ │ ├── development.rb │ │ └── test.rb │ ├── initializers │ │ ├── application_controller_renderer.rb │ │ ├── assets.rb │ │ ├── backtrace_silencers.rb │ │ ├── content_security_policy.rb │ │ ├── cookies_serializer.rb │ │ ├── dokno.rb │ │ ├── filter_parameter_logging.rb │ │ ├── inflections.rb │ │ ├── mime_types.rb │ │ └── wrap_parameters.rb │ ├── locales │ │ └── en.yml │ ├── puma.rb │ ├── routes.rb │ └── spring.rb ├── db │ ├── schema.rb │ └── seeds.rb ├── lib │ └── assets │ │ └── .keep ├── log │ └── .keep └── public │ ├── .keep │ └── favicon.ico ├── features └── index_spec.rb ├── helpers └── dokno │ └── application_helper_spec.rb ├── models └── dokno │ ├── article_spec.rb │ └── category_spec.rb ├── rails_helper.rb ├── requests └── dokno │ ├── application_spec.rb │ ├── article_spec.rb │ └── category_spec.rb └── spec_helper.rb /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI RSpec 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | 9 | jobs: 10 | build: 11 | 12 | runs-on: ubuntu-latest 13 | defaults: 14 | run: 15 | working-directory: ./ 16 | 17 | services: 18 | postgres: 19 | image: postgres:12.5 20 | ports: 21 | - 5432:5432 22 | env: 23 | POSTGRES_USER: postgres 24 | POSTGRES_PASSWORD: postgres 25 | options: >- 26 | --health-cmd pg_isready 27 | --health-interval 10s 28 | --health-timeout 5s 29 | --health-retries 5 30 | 31 | steps: 32 | - uses: actions/checkout@v2 33 | 34 | - name: Set up Ruby 3.2.2 35 | uses: ruby/setup-ruby@v1 36 | with: 37 | ruby-version: 3.2.2 38 | 39 | - name: Install Postgres client 40 | run: sudo apt-get -yqq install libpq-dev 41 | 42 | - name: Setup DB 43 | env: 44 | RAILS_ENV: test 45 | POSTGRES_USER: postgres 46 | POSTGRES_PASSWORD: postgres 47 | working-directory: ./spec/dummy 48 | run: | 49 | gem install bundler 50 | bundle install --jobs 4 --retry 3 51 | bin/rails db:create 52 | bin/rails db:migrate 53 | 54 | - name: Run Tests 55 | env: 56 | RAILS_ENV: test 57 | POSTGRES_USER: postgres 58 | POSTGRES_PASSWORD: postgres 59 | run: | 60 | gem install bundler 61 | bundle install --jobs 4 --retry 3 62 | bundle exec rspec 63 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .bundle/ 3 | log/*.log 4 | pkg/ 5 | coverage 6 | spec/dummy/db/*.sqlite3 7 | spec/dummy/db/*.sqlite3-journal 8 | spec/dummy/db/*.sqlite3-* 9 | spec/dummy/log/*.log 10 | spec/dummy/storage/ 11 | spec/dummy/tmp/ 12 | rspec_failures.txt 13 | *.gem 14 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --require spec_helper 2 | --color 3 | --profile 4 | --format documentation 5 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | All notable changes to this project are documented here. 3 | 4 | This project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). 5 | 6 | ## 1.4.14 7 | ### Updated 8 | - Development dependency security vulnerability gem updates 9 | 10 | ## 1.4.13 11 | ### Updated 12 | - Development dependency security vulnerability gem updates 13 | 14 | ## 1.4.12 15 | ### Updated 16 | - Development dependency security vulnerability gem updates 17 | - [GHSA-pxvg-2qj5-37jq](https://github.com/advisories/GHSA-pxvg-2qj5-37jq) 18 | - [GHSA-68xg-gqqm-vgj8](https://github.com/advisories/GHSA-68xg-gqqm-vgj8) 19 | - [GHSA-4g8v-vg43-wpgf](https://github.com/advisories/GHSA-4g8v-vg43-wpgf) 20 | - [GHSA-cr5q-6q9f-rq6q](https://github.com/advisories/GHSA-cr5q-6q9f-rq6q) 21 | 22 | ## 1.4.11 23 | ### Updated 24 | - Development dependency security vulnerability gem updates 25 | - [GHSA-3h57-hmj3-gj3p](https://github.com/advisories/GHSA-3h57-hmj3-gj3p) 26 | - [GHSA-579w-22j4-4749](https://github.com/advisories/GHSA-579w-22j4-4749) 27 | - [GHSA-hq7p-j377-6v63](https://github.com/advisories/GHSA-hq7p-j377-6v63) 28 | - [GHSA-5x79-w82f-gw8w](https://github.com/advisories/GHSA-5x79-w82f-gw8w) 29 | - [GHSA-486f-hjj9-9vhh](https://github.com/advisories/GHSA-486f-hjj9-9vhh) 30 | - [GHSA-3x8r-x6xp-q4vm](https://github.com/advisories/GHSA-3x8r-x6xp-q4vm) 31 | - [GHSA-qv4q-mr5r-qprj](https://github.com/advisories/GHSA-qv4q-mr5r-qprj) 32 | - [GHSA-pj73-v5mw-pm9j](https://github.com/advisories/GHSA-pj73-v5mw-pm9j) 33 | - [GHSA-9h9g-93gc-623h](https://github.com/advisories/GHSA-9h9g-93gc-623h) 34 | - [GHSA-rrfc-7g8p-99q8](https://github.com/advisories/GHSA-rrfc-7g8p-99q8) 35 | - [GHSA-mcvf-2q2m-x72m](https://github.com/advisories/GHSA-mcvf-2q2m-x72m) 36 | - [GHSA-228g-948r-83gx](https://github.com/advisories/GHSA-228g-948r-83gx) 37 | - [GHSA-2qc6-mcvw-92cw](https://github.com/advisories/GHSA-2qc6-mcvw-92cw) 38 | - [GHSA-c6qg-cjj8-47qp](https://github.com/advisories/GHSA-c6qg-cjj8-47qp) 39 | - [GHSA-8xww-x3g3-6jcv](https://github.com/advisories/GHSA-8xww-x3g3-6jcv) 40 | - [GHSA-j6gc-792m-qgm2](https://github.com/advisories/GHSA-j6gc-792m-qgm2) 41 | - [GHSA-p84v-45xj-wwqj](https://github.com/advisories/GHSA-p84v-45xj-wwqj) 42 | - [GHSA-23c2-gwp5-pxw9](https://github.com/advisories/GHSA-23c2-gwp5-pxw9) 43 | - [GHSA-93pm-5p5f-3ghx](https://github.com/advisories/GHSA-93pm-5p5f-3ghx) 44 | - [GHSA-rqv2-275x-2jq5](https://github.com/advisories/GHSA-rqv2-275x-2jq5) 45 | - [GHSA-65f5-mfpf-vfhj](https://github.com/advisories/GHSA-65f5-mfpf-vfhj) 46 | - [GHSA-9chr-4fjh-5rgw](https://github.com/advisories/GHSA-9chr-4fjh-5rgw) 47 | 48 | ## 1.4.10 49 | ### Updated 50 | - Security vulnerability gem updates 51 | - [GHSA-5ww9-9qp2-x524](https://github.com/advisories/GHSA-5ww9-9qp2-x524) 52 | - [GHSA-3hhc-qp5v-9p2j](https://github.com/advisories/GHSA-3hhc-qp5v-9p2j) 53 | - [GHSA-pg8v-g4xq-hww9](https://github.com/advisories/GHSA-pg8v-g4xq-hww9) 54 | 55 | ## 1.4.9 56 | ### Updated 57 | - Security vulnerability gem updates 58 | - [GHSA-h99w-9q5r-gjq9](https://github.com/advisories/GHSA-h99w-9q5r-gjq9) 59 | 60 | ## 1.4.8 61 | ### Updated 62 | - Security vulnerability gem updates 63 | - [GHSA-wh98-p28r-vrc9](https://github.com/advisories/GHSA-wh98-p28r-vrc9) 64 | - [GHSA-rmj8-8hhh-gv5h](https://github.com/advisories/GHSA-rmj8-8hhh-gv5h) 65 | - [GHSA-fq42-c5rg-92c2](https://github.com/advisories/GHSA-fq42-c5rg-92c2) 66 | - [GHSA-w749-p3v6-hccq](https://github.com/advisories/GHSA-w749-p3v6-hccq) 67 | 68 | ## 1.4.7 69 | ### Updated 70 | - Security vulnerability gem updates 71 | - [CVE-2021-41098](https://github.com/advisories/GHSA-2rr5-8q37-2w7h) 72 | - [CVE-2021-41136](https://github.com/advisories/GHSA-48w2-rm65-62xx) 73 | 74 | ## 1.4.6 75 | ### Updated 76 | - Security vulnerability gem updates 77 | - [CVE-2021-22942](https://github.com/advisories/GHSA-2rqw-v265-jf8c) 78 | 79 | ## 1.4.5 80 | ### Updated 81 | - Security vulnerability gem updates 82 | - [CVE-2021-32740](https://github.com/advisories/GHSA-jxhc-q857-3j6g) 83 | 84 | ## 1.4.4 85 | ### Updated 86 | - Security vulnerability gem updates 87 | - [CVE-2019-16770](https://github.com/puma/puma/security/advisories/GHSA-7xx3-m584-x994) 88 | 89 | ## 1.4.3 90 | ### Updated 91 | - Security vulnerability gem updates 92 | - [CVE-2021-22902](https://github.com/advisories/GHSA-g8ww-46x2-2p65) 93 | 94 | ## 1.4.1 95 | ### Updated 96 | - Security vulnerability gem updates 97 | - [CVE-2021-22880](https://github.com/advisories/GHSA-8hc4-xxm3-5ppp) 98 | - [CVE-2021-22881](https://github.com/advisories/GHSA-8877-prq4-9xfw) 99 | - [GHSA-vr8q-g5c7-m54m](https://github.com/advisories/GHSA-vr8q-g5c7-m54m) 100 | 101 | ## 1.4.0 102 | ### Added 103 | - `dokno-link` class to the `dokno_article_link` helper markup to facilitate link styling by the host 104 | - The ability to quickly return to the prior index page location when viewing an article 105 | - Caching of the category option list within the Category form 106 | 107 | ### Changed 108 | - Various code re-org 109 | 110 | ### Fixed 111 | - Problem when the host app has an improperly configured `app_user_object` setting raising a `nil` error during initialization 112 | 113 | ## 1.3.0 114 | ### Added 115 | - Up for review articles 116 | - Starred articles 117 | 118 | ## 1.2.1 119 | ### Fixed 120 | - Problem with invalidating the category option cache when no category is yet in the database 121 | 122 | ## 1.2.0 123 | ### Added 124 | - Search hotkey 125 | - Category context to article pages 126 | - Article counts to category options list 127 | - Capybara tests 128 | 129 | ## 1.1.1 130 | ### Added 131 | - Caching category hierarchy SELECT list OPTIONs 132 | 133 | ### Changed 134 | - Increased font weight in flyout articles 135 | - Removed extraneous metadata from printed articles 136 | - Improved Faker seed data 137 | 138 | ### Fixed 139 | - Search results count showed records on page instead of total results 140 | - Changing page number via input caused loss of category context 141 | 142 | ## 1.1.0 143 | ### Added 144 | - Ability to delete categories 145 | - User action flash status messages 146 | - Ability to close flyout articles by clicking outside of them 147 | - Clickable breadcrumbs to the top of article pages 148 | - Breadcrumbs to the top of category index pages when the category has 1+ parent 149 | 150 | ### Changed 151 | - Stripping all surrounding whitespace from `dokno_article_link` helper 152 | - Improved category hierarchy dropdown appearance 153 | - Auto-focusing on first field on all forms 154 | 155 | ## 1.0.0 156 | Public API. Usable release :tada: 157 | 158 | ### Added 159 | - 100% spec coverage 160 | - Search term highlighting on article index pages, detail pages, and print views 161 | - Article permalink support 162 | - Article view count tracking 163 | - Article change logging 164 | - Article index page pagination 165 | - Article index page sorting by title, views, and dates created or updated 166 | 167 | ### Changed 168 | - README 169 | 170 | ## 0.1.0 171 | ### Added 172 | - Baseline 173 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | git_source(:github) { |repo| "https://github.com/#{repo}.git" } 3 | 4 | gemspec 5 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: . 3 | specs: 4 | dokno (1.4.13) 5 | diffy (~> 3.4) 6 | redcarpet (~> 3.6) 7 | 8 | GEM 9 | remote: https://rubygems.org/ 10 | specs: 11 | actioncable (8.0.2) 12 | actionpack (= 8.0.2) 13 | activesupport (= 8.0.2) 14 | nio4r (~> 2.0) 15 | websocket-driver (>= 0.6.1) 16 | zeitwerk (~> 2.6) 17 | actionmailbox (8.0.2) 18 | actionpack (= 8.0.2) 19 | activejob (= 8.0.2) 20 | activerecord (= 8.0.2) 21 | activestorage (= 8.0.2) 22 | activesupport (= 8.0.2) 23 | mail (>= 2.8.0) 24 | actionmailer (8.0.2) 25 | actionpack (= 8.0.2) 26 | actionview (= 8.0.2) 27 | activejob (= 8.0.2) 28 | activesupport (= 8.0.2) 29 | mail (>= 2.8.0) 30 | rails-dom-testing (~> 2.2) 31 | actionpack (8.0.2) 32 | actionview (= 8.0.2) 33 | activesupport (= 8.0.2) 34 | nokogiri (>= 1.8.5) 35 | rack (>= 2.2.4) 36 | rack-session (>= 1.0.1) 37 | rack-test (>= 0.6.3) 38 | rails-dom-testing (~> 2.2) 39 | rails-html-sanitizer (~> 1.6) 40 | useragent (~> 0.16) 41 | actiontext (8.0.2) 42 | actionpack (= 8.0.2) 43 | activerecord (= 8.0.2) 44 | activestorage (= 8.0.2) 45 | activesupport (= 8.0.2) 46 | globalid (>= 0.6.0) 47 | nokogiri (>= 1.8.5) 48 | actionview (8.0.2) 49 | activesupport (= 8.0.2) 50 | builder (~> 3.1) 51 | erubi (~> 1.11) 52 | rails-dom-testing (~> 2.2) 53 | rails-html-sanitizer (~> 1.6) 54 | activejob (8.0.2) 55 | activesupport (= 8.0.2) 56 | globalid (>= 0.3.6) 57 | activemodel (8.0.2) 58 | activesupport (= 8.0.2) 59 | activerecord (8.0.2) 60 | activemodel (= 8.0.2) 61 | activesupport (= 8.0.2) 62 | timeout (>= 0.4.0) 63 | activestorage (8.0.2) 64 | actionpack (= 8.0.2) 65 | activejob (= 8.0.2) 66 | activerecord (= 8.0.2) 67 | activesupport (= 8.0.2) 68 | marcel (~> 1.0) 69 | activesupport (8.0.2) 70 | base64 71 | benchmark (>= 0.3) 72 | bigdecimal 73 | concurrent-ruby (~> 1.0, >= 1.3.1) 74 | connection_pool (>= 2.2.5) 75 | drb 76 | i18n (>= 1.6, < 2) 77 | logger (>= 1.4.2) 78 | minitest (>= 5.1) 79 | securerandom (>= 0.3) 80 | tzinfo (~> 2.0, >= 2.0.5) 81 | uri (>= 0.13.1) 82 | addressable (2.8.7) 83 | public_suffix (>= 2.0.2, < 7.0) 84 | base64 (0.2.0) 85 | benchmark (0.4.0) 86 | bigdecimal (3.1.9) 87 | builder (3.3.0) 88 | byebug (12.0.0) 89 | capybara (3.40.0) 90 | addressable 91 | matrix 92 | mini_mime (>= 0.1.3) 93 | nokogiri (~> 1.11) 94 | rack (>= 1.6.0) 95 | rack-test (>= 0.6.3) 96 | regexp_parser (>= 1.5, < 3.0) 97 | xpath (~> 3.2) 98 | coderay (1.1.3) 99 | concurrent-ruby (1.3.5) 100 | connection_pool (2.5.0) 101 | crass (1.0.6) 102 | database_cleaner-active_record (2.2.0) 103 | activerecord (>= 5.a) 104 | database_cleaner-core (~> 2.0.0) 105 | database_cleaner-core (2.0.1) 106 | date (3.4.1) 107 | diff-lcs (1.6.1) 108 | diffy (3.4.3) 109 | docile (1.4.1) 110 | drb (2.2.1) 111 | erubi (1.13.1) 112 | faker (3.5.1) 113 | i18n (>= 1.8.11, < 2) 114 | globalid (1.2.1) 115 | activesupport (>= 6.1) 116 | i18n (1.14.7) 117 | concurrent-ruby (~> 1.0) 118 | io-console (0.8.0) 119 | irb (1.15.1) 120 | pp (>= 0.6.0) 121 | rdoc (>= 4.0.0) 122 | reline (>= 0.4.2) 123 | logger (1.7.0) 124 | loofah (2.24.0) 125 | crass (~> 1.0.2) 126 | nokogiri (>= 1.12.0) 127 | mail (2.8.1) 128 | mini_mime (>= 0.1.1) 129 | net-imap 130 | net-pop 131 | net-smtp 132 | marcel (1.0.4) 133 | matrix (0.4.2) 134 | method_source (1.1.0) 135 | mini_mime (1.1.5) 136 | minitest (5.25.5) 137 | net-imap (0.5.6) 138 | date 139 | net-protocol 140 | net-pop (0.1.2) 141 | net-protocol 142 | net-protocol (0.2.2) 143 | timeout 144 | net-smtp (0.5.1) 145 | net-protocol 146 | nio4r (2.7.4) 147 | nokogiri (1.18.7-arm64-darwin) 148 | racc (~> 1.4) 149 | pg (1.5.9) 150 | pp (0.6.2) 151 | prettyprint 152 | prettyprint (0.2.0) 153 | pry (0.15.2) 154 | coderay (~> 1.1) 155 | method_source (~> 1.0) 156 | pry-byebug (3.11.0) 157 | byebug (~> 12.0) 158 | pry (>= 0.13, < 0.16) 159 | psych (5.2.3) 160 | date 161 | stringio 162 | public_suffix (6.0.1) 163 | puma (6.6.0) 164 | nio4r (~> 2.0) 165 | racc (1.8.1) 166 | rack (3.1.12) 167 | rack-session (2.1.0) 168 | base64 (>= 0.1.0) 169 | rack (>= 3.0.0) 170 | rack-test (2.2.0) 171 | rack (>= 1.3) 172 | rackup (2.2.1) 173 | rack (>= 3) 174 | rails (8.0.2) 175 | actioncable (= 8.0.2) 176 | actionmailbox (= 8.0.2) 177 | actionmailer (= 8.0.2) 178 | actionpack (= 8.0.2) 179 | actiontext (= 8.0.2) 180 | actionview (= 8.0.2) 181 | activejob (= 8.0.2) 182 | activemodel (= 8.0.2) 183 | activerecord (= 8.0.2) 184 | activestorage (= 8.0.2) 185 | activesupport (= 8.0.2) 186 | bundler (>= 1.15.0) 187 | railties (= 8.0.2) 188 | rails-dom-testing (2.2.0) 189 | activesupport (>= 5.0.0) 190 | minitest 191 | nokogiri (>= 1.6) 192 | rails-html-sanitizer (1.6.2) 193 | loofah (~> 2.21) 194 | nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0) 195 | railties (8.0.2) 196 | actionpack (= 8.0.2) 197 | activesupport (= 8.0.2) 198 | irb (~> 1.13) 199 | rackup (>= 1.0.0) 200 | rake (>= 12.2) 201 | thor (~> 1.0, >= 1.2.2) 202 | zeitwerk (~> 2.6) 203 | rake (13.2.1) 204 | rdoc (6.13.1) 205 | psych (>= 4.0.0) 206 | redcarpet (3.6.1) 207 | regexp_parser (2.10.0) 208 | reline (0.6.0) 209 | io-console (~> 0.5) 210 | rexml (3.4.1) 211 | rspec-core (3.13.3) 212 | rspec-support (~> 3.13.0) 213 | rspec-expectations (3.13.3) 214 | diff-lcs (>= 1.2.0, < 2.0) 215 | rspec-support (~> 3.13.0) 216 | rspec-mocks (3.13.2) 217 | diff-lcs (>= 1.2.0, < 2.0) 218 | rspec-support (~> 3.13.0) 219 | rspec-rails (7.1.1) 220 | actionpack (>= 7.0) 221 | activesupport (>= 7.0) 222 | railties (>= 7.0) 223 | rspec-core (~> 3.13) 224 | rspec-expectations (~> 3.13) 225 | rspec-mocks (~> 3.13) 226 | rspec-support (~> 3.13) 227 | rspec-support (3.13.2) 228 | rubyzip (2.4.1) 229 | securerandom (0.4.1) 230 | selenium-webdriver (4.30.1) 231 | base64 (~> 0.2) 232 | logger (~> 1.4) 233 | rexml (~> 3.2, >= 3.2.5) 234 | rubyzip (>= 1.2.2, < 3.0) 235 | websocket (~> 1.0) 236 | simplecov (0.22.0) 237 | docile (~> 1.1) 238 | simplecov-html (~> 0.11) 239 | simplecov_json_formatter (~> 0.1) 240 | simplecov-html (0.13.1) 241 | simplecov_json_formatter (0.1.4) 242 | sprockets (4.2.1) 243 | concurrent-ruby (~> 1.0) 244 | rack (>= 2.2.4, < 4) 245 | sprockets-rails (3.5.2) 246 | actionpack (>= 6.1) 247 | activesupport (>= 6.1) 248 | sprockets (>= 3.0.0) 249 | stringio (3.1.6) 250 | thor (1.3.2) 251 | timeout (0.4.3) 252 | tzinfo (2.0.6) 253 | concurrent-ruby (~> 1.0) 254 | uri (1.0.3) 255 | useragent (0.16.11) 256 | websocket (1.2.11) 257 | websocket-driver (0.7.7) 258 | base64 259 | websocket-extensions (>= 0.1.0) 260 | websocket-extensions (0.1.5) 261 | xpath (3.2.0) 262 | nokogiri (~> 1.8) 263 | zeitwerk (2.7.2) 264 | 265 | PLATFORMS 266 | arm64-darwin-22 267 | 268 | DEPENDENCIES 269 | capybara 270 | database_cleaner-active_record 271 | dokno! 272 | faker 273 | pg 274 | pry-byebug 275 | puma 276 | rails (~> 8) 277 | rspec-rails 278 | selenium-webdriver 279 | simplecov 280 | sprockets-rails 281 | 282 | BUNDLED WITH 283 | 2.4.10 284 | -------------------------------------------------------------------------------- /MIT-LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2020 Courtney Payne 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Dokno 2 | 3 | > [!WARNING] 4 | > This project is largely unmaintained now. I'll happily accept PRs to keep things in working order, but I no longer plan to make updates. 5 | 6 | ![CI RSpec](https://github.com/cpayne624/dokno/workflows/CI%20RSpec/badge.svg) [![Gem Version](https://badge.fury.io/rb/dokno.svg)](https://badge.fury.io/rb/dokno) 7 | 8 | Dokno (e.g., "I _do' kno'_ the answer") is a lightweight Rails Engine for storing and managing your app's domain knowledge. 9 | 10 | It provides a repository to store information about your app for posterity, such as term definitions, system logic and implementation details, background for past system design decisions, or anything else related to your app that would be beneficial for you and your users to know, now and in the future. 11 | 12 | Article Screenshot 13 | 14 | ## Installation 15 | Add this line to your application's Gemfile: 16 | ```ruby 17 | gem 'dokno' 18 | ``` 19 | 20 | From your app's root folder, run: 21 | ```bash 22 | $ bundle 23 | ``` 24 | 25 | Run Dokno migrations to add the Dokno article and category tables to your db: 26 | ```bash 27 | $ rake db:migrate 28 | ``` 29 | 30 | Mount Dokno in your `/config/routes.rb` at the desired path: 31 | ```ruby 32 | mount Dokno::Engine, at: "/help" 33 | ``` 34 | 35 | Initialize Dokno configuration: 36 | ```bash 37 | $ rails g dokno:install 38 | ``` 39 | 40 | To enable [in-context articles](#in-context-article-links) in your app, add the supporting partial to the bottom of your application's `app/views/layouts/application.html.erb`, just above the closing `` tag: 41 | ```erb 42 | <%= render 'dokno/article_panel' %> 43 | ``` 44 | 45 | ### Configuration 46 | 47 | #### Dokno Settings 48 | 49 | Running `rails g dokno:install` creates `/config/initializers/dokno.rb` within your app, containing the available Dokno configuration options. Remember to restart your app whenever you make configuration changes. 50 | 51 | ### Articles 52 | 53 | #### Accessing Articles 54 | 55 | Each article is accessible via its unique [slug](https://en.wikipedia.org/wiki/Clean_URL#Slug), either through its [permalink](https://en.wikipedia.org/wiki/Permalink) to view the article within the Dokno site, or by an [in-context flyout link](#in-context-article-links) within your app. 56 | 57 | Articles can be assigned to 0+ categories, and categories can be "infinitely" nested. 58 | 59 | #### Authoring Articles 60 | 61 | By default, any visitor to the knowledgebase site can modify data. 62 | 63 | See `/config/initializers/dokno.rb` to link Dokno to the model in your app that stores your users. This will allow you to restrict Dokno data modification to certain users, and to include indentifying information in article change logs. 64 | 65 | Articles can include basic HTML and [markdown](https://commonmark.org/help/), and you can configure a starting template for all new articles. See `/config/dokno_template.md`. 66 | 67 | ## Usage 68 | 69 | ### Accessing the Knowledgebase Site 70 | The Dokno knowledgebase is mounted to the path you specified in your `/config/routes.rb` above. You can use the `dokno_path` route helper to link your users to the knowledgebase site. 71 | 72 | Knowledgebase 73 | 74 | ### In-Context Article Links 75 | Each article has a unique `slug` or token that is used to access it from within your app. Use the `dokno_article_link` helper to add article links. 76 | 77 | Clicking a link fetches the article asynchronously and reveals it to the user via flyout panel overlay within your app. 78 | 79 | <%= dokno_article_link({link-text}, slug: {unique-article-slug}) %> 80 | 81 | ### Dokno Data Querying 82 | You typically won't need to interact with Dokno data directly, but it is stored within your database and is accessible via ActiveRecord as is any other model. 83 | 84 | Dokno data is `Dokno::` namespaced and can be accessed directly via: 85 | 86 | ```ruby 87 | Dokno::Article.all 88 | => #, ...] 89 | Dokno::Article.take.categories 90 | => #, ...] 91 | 92 | Dokno::Category.all 93 | => #, ...] 94 | Dokno::Category.take.articles 95 | => #, ...] 96 | 97 | Dokno::Category.take.parent 98 | => # 99 | 100 | Dokno::Category.take.children 101 | => #, ...] 102 | ``` 103 | 104 | ## Dependencies 105 | Dokno is purposefully lightweight, with minimal dependencies. 106 | 107 | It has two dependencies: the [redcarpet](https://github.com/vmg/redcarpet) gem for markdown processing, and the [diffy](https://github.com/samg/diffy) gem for change diffing, neither of which have further dependencies. Both are excellent. 108 | 109 | ## Pull Requests 110 | Contributions are welcome. 111 | 112 | ### Proposing a Solution 113 | Before starting on a PR, check [open issues](https://github.com/cpayne624/dokno/issues) and [pull requests](https://github.com/cpayne624/dokno/pulls) to make sure your enhancement idea or bug report isn't already being worked. 114 | 115 | If not, [open an issue](https://github.com/cpayne624/dokno/issues) to first discuss the proposed solution before beginning work. 116 | 117 | ### Providing Proper Test Coverage 118 | Before submitting your PR, make sure that all existing specs still pass, and that any _new_ functionality you've added is covered by additional specs. 119 | 120 | To run the test suite: 121 | ```bash 122 | $ bundle exec rspec 123 | ``` 124 | 125 | ## Hat Tip 126 | - [tailwindcss](https://tailwindcss.com/) 127 | - CSS framework 128 | - [diffy](https://github.com/samg/diffy) 129 | - Text diffing Ruby gem 130 | - [Feather Icons](https://github.com/feathericons/feather) 131 | - Icon library 132 | - [redcarpet](https://github.com/vmg/redcarpet) 133 | - Markdown parsing Ruby gem 134 | 135 | ## License 136 | The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT). 137 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | begin 2 | require 'bundler/setup' 3 | rescue LoadError 4 | puts 'You must `gem install bundler` and `bundle install` to run rake tasks' 5 | end 6 | 7 | require 'rdoc/task' 8 | 9 | RDoc::Task.new(:rdoc) do |rdoc| 10 | rdoc.rdoc_dir = 'rdoc' 11 | rdoc.title = 'Dokno' 12 | rdoc.options << '--line-numbers' 13 | rdoc.rdoc_files.include('README.md') 14 | rdoc.rdoc_files.include('lib/**/*.rb') 15 | end 16 | 17 | APP_RAKEFILE = File.expand_path("spec/dummy/Rakefile", __dir__) 18 | load 'rails/tasks/engine.rake' 19 | 20 | load 'rails/tasks/statistics.rake' 21 | 22 | require 'bundler/gem_tasks' 23 | -------------------------------------------------------------------------------- /app/assets/config/dokno_manifest.js: -------------------------------------------------------------------------------- 1 | //= link_directory ../stylesheets/dokno .css 2 | //= link_directory ../javascripts/dokno .js 3 | //= link_directory ../javascripts .js 4 | -------------------------------------------------------------------------------- /app/assets/images/dokno/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cpayne624/dokno/86c8b2aeb960b6bd4b63fd63117b541d47f49fe9/app/assets/images/dokno/.keep -------------------------------------------------------------------------------- /app/assets/javascripts/dokno.js: -------------------------------------------------------------------------------- 1 | const dokno__search_hotkey_listener = function(e) { 2 | if (e.key === '/') { handleSearchHotKey(); } 3 | } 4 | 5 | function handleSearchHotKey() { 6 | const search_input = elem('input#search_term'); 7 | search_input.focus(); 8 | search_input.select(); 9 | } 10 | 11 | function enableSearchHotkey() { 12 | document.addEventListener('keyup', dokno__search_hotkey_listener, false); 13 | } 14 | 15 | function disableSearchHotkey() { 16 | document.removeEventListener('keyup', dokno__search_hotkey_listener, false); 17 | } 18 | 19 | function copyToClipboard(text) { 20 | window.prompt('Copy to clipboard: CTRL + C, Enter', text); 21 | } 22 | 23 | function elem(selector) { 24 | return document.querySelector(selector); 25 | } 26 | 27 | function elems(selector) { 28 | return document.getElementsByClassName(selector); 29 | } 30 | 31 | function selectOption(id, value) { 32 | var sel = elem('#' + id); 33 | var opts = sel.options; 34 | 35 | for (var opt, j = 0; opt = opts[j]; j++) { 36 | if (opt.value == value) { 37 | sel.selectedIndex = j; 38 | break; 39 | } 40 | } 41 | } 42 | 43 | function applyCategoryCriteria(category_code, term, order) { 44 | goToPage(dokno__base_path + category_code + '?search_term=' + term + '&order=' + order); 45 | } 46 | 47 | function goToPage(url) { 48 | var param_join = url.indexOf('?') >= 0 ? '&' : '?'; 49 | location.href = url + param_join + '_=' + Math.round(new Date().getTime()); 50 | } 51 | 52 | function reloadPage() { 53 | window.onbeforeunload = function () { window.scrollTo(0, 0); } 54 | location.reload(); 55 | } 56 | 57 | function sendRequest(url, data, callback, method) { 58 | const request = new XMLHttpRequest(); 59 | request.open(method, url, true); 60 | request.setRequestHeader('X-CSRF-Token', elem('meta[name="csrf-token"]').getAttribute('content')); 61 | request.setRequestHeader('Content-Type', 'application/json'); 62 | request.onload = function() { 63 | if (request.readyState == 4 && request.status == 200) { 64 | try { 65 | var data = JSON.parse(request.responseText); 66 | } catch(_e) { 67 | var data = request.responseText; 68 | } 69 | 70 | callback(data); 71 | } 72 | }; 73 | 74 | request.send(JSON.stringify(data)); 75 | } 76 | 77 | function setArticleStatus(slug, active) { 78 | const callback = function(_data) { reloadPage(); } 79 | sendRequest(dokno__base_path + 'article_status', { slug: slug, active: active }, callback, 'POST'); 80 | } 81 | 82 | function deleteArticle(id) { 83 | if (!confirm('Permanently Delete Article\n\nAre you sure you want to permanently delete this article?\n\nThis is irreversible.')) { 84 | return true; 85 | } 86 | 87 | const callback = function(_data) { 88 | location.href = dokno__base_path; 89 | } 90 | sendRequest(dokno__base_path + 'articles/' + id, {}, callback, 'DELETE'); 91 | } 92 | 93 | function deleteCategory(id) { 94 | if (!confirm('Delete Category\n\nThis will remove this category. Any articles in this category will become uncategorized and appear on the home page until re-categorized.')) { 95 | return true; 96 | } 97 | 98 | const callback = function(_data) { 99 | location.href = dokno__base_path; 100 | } 101 | sendRequest(dokno__base_path + 'categories/' + id, {}, callback, 'DELETE'); 102 | } 103 | 104 | function previewArticleToggle() { 105 | const markdown = elem('div#dokno-content-container textarea#markdown').value; 106 | const callback = function(data) { 107 | elem('div#dokno-content-container div#markdown_preview').innerHTML = data.parsed_content; 108 | elem('div#dokno-content-container textarea#markdown').classList.add('hidden'); 109 | elem('div#dokno-content-container a#markdown_preview_link').classList.add('hidden'); 110 | elem('div#dokno-content-container div#markdown_preview').classList.remove('hidden'); 111 | elem('div#dokno-content-container a#markdown_write_link').classList.remove('hidden'); 112 | } 113 | sendRequest(dokno__base_path + 'article_preview', { markdown: markdown }, callback, 'POST'); 114 | } 115 | 116 | function writeArticleToggle() { 117 | elem('div#dokno-content-container textarea#markdown').classList.remove('hidden'); 118 | elem('div#dokno-content-container a#markdown_preview_link').classList.remove('hidden'); 119 | elem('div#dokno-content-container div#markdown_preview').classList.add('hidden'); 120 | elem('div#dokno-content-container a#markdown_write_link').classList.add('hidden'); 121 | } 122 | 123 | function toggleVisibility(selector_id) { 124 | var $elem = elem('#' + selector_id); 125 | var $icon = elem('svg.' + selector_id); 126 | var icon_class, new_icon; 127 | 128 | if (!$elem) { 129 | return true; 130 | } 131 | 132 | if ($elem.classList.contains('hidden')) { 133 | $elem.classList.remove('hidden'); 134 | icon_class = 'chevron-down'; 135 | } else { 136 | $elem.classList.add('hidden'); 137 | icon_class = 'chevron-left'; 138 | } 139 | 140 | if (!$icon) { 141 | return true; 142 | } 143 | 144 | new_icon = document.createElement('i'); 145 | new_icon.setAttribute('data-feather', icon_class); 146 | new_icon.classList.add('inline'); 147 | new_icon.classList.add('toggle-visibility-indicator'); 148 | new_icon.classList.add(selector_id); 149 | 150 | $icon.remove(); 151 | elem('div.toggle-visibility-indicator-container.' + selector_id).appendChild(new_icon); 152 | initIcons(); 153 | } 154 | 155 | // Pass containers_selector as class name (no prefix) 156 | function highlightTerm(terms, containers_selector) { 157 | var containers = elems(containers_selector); 158 | 159 | for (var i=0, len=containers.length|0; i { 163 | content = content.replace(new RegExp(term, 'gi'), (match) => wrapTermWithHTML(match)); 164 | }); 165 | 166 | containers[i].innerHTML = content; 167 | } 168 | } 169 | 170 | function wrapTermWithHTML(term) { 171 | return `${term}` 172 | } 173 | 174 | function setReviewForm() { 175 | const reset_review_date_checkbox = elem('input#reset_review_date'); 176 | const review_notes_textarea = elem('textarea#review_notes'); 177 | 178 | if (!reset_review_date_checkbox) { 179 | return true; 180 | } 181 | 182 | if (reset_review_date_checkbox.checked) { 183 | review_notes_textarea.removeAttribute('disabled'); 184 | review_notes_textarea.classList.remove('cursor-not-allowed'); 185 | review_notes_textarea.focus(); 186 | } else { 187 | review_notes_textarea.setAttribute('disabled', 'disabled'); 188 | review_notes_textarea.classList.add('cursor-not-allowed'); 189 | reset_review_date_checkbox.focus(); 190 | } 191 | } 192 | -------------------------------------------------------------------------------- /app/assets/javascripts/dokno/application.js: -------------------------------------------------------------------------------- 1 | // This is a manifest file that'll be compiled into application.js, which will include all the files 2 | // listed below. 3 | // 4 | // Any JavaScript/Coffee file within this directory, lib/assets/javascripts, vendor/assets/javascripts, 5 | // or any plugin's vendor/assets/javascripts directory can be referenced here using a relative path. 6 | // 7 | // It's not advisable to add code directly here, but if you do, it'll appear at the bottom of the 8 | // compiled file. 9 | // 10 | // Read Sprockets README (https://github.com/sstephenson/sprockets#sprockets-directives) for details 11 | // about supported directives. 12 | // 13 | //= require feather.min 14 | //= require dokno 15 | //= require init 16 | -------------------------------------------------------------------------------- /app/assets/javascripts/init.js: -------------------------------------------------------------------------------- 1 | function initIcons() { 2 | feather.replace(); 3 | } 4 | 5 | initIcons(); -------------------------------------------------------------------------------- /app/assets/stylesheets/dokno/application.css: -------------------------------------------------------------------------------- 1 | /* 2 | * This is a manifest file that'll be compiled into application.css, which will include all the files 3 | * listed below. 4 | * 5 | * Any CSS and SCSS file within this directory, lib/assets/stylesheets, vendor/assets/stylesheets, 6 | * or any plugin's vendor/assets/stylesheets directory can be referenced here using a relative path. 7 | * 8 | * You're free to add application-wide styles to this file and they'll appear at the bottom of the 9 | * compiled file so the styles you add here take precedence over styles defined in any other CSS/SCSS 10 | * files in this directory. Styles in this file should be added after the last require_* statement. 11 | * It is generally better to create a new file per style scope. 12 | * 13 | *= require_tree . 14 | *= require_self 15 | */ 16 | 17 | /* Additional knowledgebase site styles that are not in tailwind */ 18 | 19 | button svg.feather { 20 | vertical-align: sub; 21 | } 22 | 23 | div#dokno-content-container a { 24 | font-weight: 400; 25 | color: #2c5282; 26 | } 27 | 28 | .no-underline { 29 | text-decoration: none !important; 30 | } 31 | 32 | div.diff del { 33 | color: rgb(245, 158, 11); 34 | text-decoration: none; 35 | } 36 | 37 | div.diff ins { 38 | color: rgb(254, 243, 199); 39 | text-decoration: none; 40 | } 41 | 42 | div.diff ins strong { 43 | background-color: rgb(254, 243, 199); 44 | color: rgb(26,32,44); 45 | } 46 | 47 | div.diff del strong { 48 | background-color: rgb(245, 158, 11); 49 | color: rgb(26,32,44); 50 | text-decoration: line-through; 51 | } 52 | 53 | div#change-log { 54 | max-height: 2000px; 55 | overflow: hidden; 56 | overflow-y: auto; 57 | } 58 | 59 | /* Printing */ 60 | @media print { 61 | /* @page { size: landscape; } */ 62 | 63 | body * { 64 | visibility: hidden; 65 | } 66 | 67 | body { 68 | background-color: #fff !important; 69 | } 70 | 71 | #dokno-content-container, 72 | #dokno-content-container * { 73 | visibility: visible; 74 | color: #000 !important; 75 | font-weight: 400 !important; 76 | } 77 | 78 | #dokno-content-container * { 79 | font-size: 100%; 80 | } 81 | 82 | #dokno-content-container { 83 | position: absolute; 84 | left: 0; 85 | top: 0; 86 | } 87 | 88 | #dokno-article-sidebar, 89 | #dokno-article-content { 90 | width: 100% !important; 91 | padding: 0 !important; 92 | } 93 | 94 | #dokno-article-content #dokno-article-content-markup { 95 | background-color: #fff !important; 96 | padding: 1.25rem 0 0 0 !important; 97 | border-top: 1px solid #000 !important; 98 | } 99 | 100 | #dokno-article-content #dokno-article-content-markup b, 101 | #dokno-article-content #dokno-article-content-markup strong { 102 | font-weight: 700 !important; 103 | } 104 | 105 | #dokno-article-sidebar { 106 | margin-bottom: 1.25rem; 107 | } 108 | 109 | #dokno-content-container h1, 110 | #dokno-content-container h2, 111 | #dokno-content-container h3, 112 | #dokno-content-container h4, 113 | #dokno-content-container h5, 114 | #dokno-content-container h6 { 115 | font-weight: 700 !important; 116 | margin-bottom: 1rem; 117 | } 118 | 119 | #dokno-content-container h1 { font-size: 150%; } 120 | #dokno-content-container h2 { font-size: 140%; } 121 | #dokno-content-container h3 { font-size: 130%; } 122 | #dokno-content-container h4 { font-size: 120%; } 123 | #dokno-content-container h5 { font-size: 110%; } 124 | 125 | .dokno-search-term { 126 | border: 2px solid #000; 127 | border-radius: 3px; 128 | } 129 | 130 | .flex { 131 | display: block !important; 132 | } 133 | 134 | .no-print { 135 | display: none !important; 136 | } 137 | } 138 | -------------------------------------------------------------------------------- /app/controllers/dokno/application_controller.rb: -------------------------------------------------------------------------------- 1 | module Dokno 2 | class ApplicationController < ::ApplicationController 3 | protect_from_forgery with: :exception 4 | 5 | include UserConcern 6 | include PaginationConcern 7 | 8 | add_flash_types :green, :yellow, :red, :gray 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /app/controllers/dokno/articles_controller.rb: -------------------------------------------------------------------------------- 1 | require_dependency 'dokno/application_controller' 2 | 3 | module Dokno 4 | class ArticlesController < ApplicationController 5 | before_action :authorize, except: [:show, :panel] 6 | before_action :fetch_article, only: [:show, :edit, :panel, :status] 7 | before_action :update_views, only: [:show] 8 | 9 | def show 10 | redirect_to root_path if @article.blank? 11 | 12 | @search_term = params[:search_term] 13 | @order = params[:order] 14 | @category = Category.find_by(code: params[:cat_code].to_s.strip) if params[:cat_code].present? 15 | @category = @article.categories.first if @category.blank? 16 | 17 | if !@article.active 18 | flash.now[:yellow] = 'This article is no longer active' 19 | elsif @article.up_for_review? 20 | flash_msg = @article.review_due_days_string 21 | flash_msg += " - review it now" if can_edit? 22 | flash.now[:gray] = flash_msg 23 | end 24 | end 25 | 26 | def new 27 | @article = Article.new 28 | @template = Article.template 29 | @category_codes = [] << params[:category_code] 30 | end 31 | 32 | def edit 33 | return redirect_to root_path if @article.blank? 34 | @category_codes = @article.categories.pluck(:code) 35 | end 36 | 37 | def create 38 | @article = Article.new(article_params) 39 | set_editor_username 40 | 41 | if @article.save 42 | flash[:green] = 'Article was created' 43 | @article.categories = Category.where(code: params[:category_code]) if params[:category_code].present? 44 | redirect_to article_path @article.slug 45 | else 46 | flash.now[:red] = 'Article could not be created' 47 | @category_codes = params[:category_code] 48 | render :new 49 | end 50 | end 51 | 52 | def update 53 | @article = Article.find_by(id: params[:id].to_i) 54 | return redirect_to root_path if @article.blank? 55 | 56 | set_editor_username 57 | 58 | if @article.update(article_params) 59 | flash[:green] = 'Article was updated' 60 | @article.categories = Category.where(code: params[:category_code]) 61 | redirect_to article_path @article.slug 62 | else 63 | flash.now[:red] = 'Article could not be updated' 64 | @category_codes = params[:category_code] 65 | @reset_review_date = params[:reset_review_date] 66 | @review_notes = params[:review_notes] 67 | render :edit 68 | end 69 | end 70 | 71 | def destroy 72 | Article.find(params[:id].to_i).destroy! 73 | 74 | flash[:green] = 'Article was deleted' 75 | render json: {}, layout: false 76 | end 77 | 78 | # Ajax-fetched slide-in article panel for the host app 79 | def panel 80 | render json: @article&.host_panel_hash, layout: false 81 | end 82 | 83 | # Ajax-fetched preview of article content from article form 84 | def preview 85 | content = Article.parse_markdown params['markdown'] 86 | render json: { parsed_content: content.presence || 'Nothing to preview' }, layout: false 87 | end 88 | 89 | # Ajax-invoked article status changing 90 | def status 91 | set_editor_username 92 | @article.update!(active: params[:active]) 93 | render json: {}, layout: false 94 | end 95 | 96 | private 97 | 98 | def article_params 99 | params.permit(:slug, :title, :summary, :markdown, :reset_review_date, :review_notes, :starred) 100 | end 101 | 102 | def fetch_article 103 | # Find by slug 104 | slug = (params[:slug].presence || params[:id]).to_s.strip 105 | @article = Article.find_by(slug: slug) 106 | return if @article.present? 107 | 108 | # Find by an old slug that has been changed (redirect to current slug) 109 | old_slug = ArticleSlug.find_by(slug: slug) 110 | return redirect_to article_path(old_slug.article.slug) if old_slug.present? 111 | 112 | # Find by ID (redirect to slug) 113 | article = Article.find_by(id: params[:id].to_i) 114 | return redirect_to article_path(article.slug) if article.present? 115 | end 116 | 117 | def set_editor_username 118 | @article&.editor_username = username 119 | end 120 | 121 | def update_views 122 | return unless @article.present? 123 | return unless (@article.last_viewed_at || 2.minutes.ago) < 1.minute.ago 124 | 125 | # No callbacks here, intentionally 126 | @article.update_columns(views: (@article.views + 1), last_viewed_at: Time.now.utc) 127 | end 128 | end 129 | end 130 | -------------------------------------------------------------------------------- /app/controllers/dokno/categories_controller.rb: -------------------------------------------------------------------------------- 1 | require_dependency 'dokno/application_controller' 2 | 3 | module Dokno 4 | class CategoriesController < ApplicationController 5 | before_action :authorize, except: [:index] 6 | before_action :fetch_category, only: [:index, :edit, :update] 7 | 8 | def index 9 | @search_term = params[:search_term] 10 | @order = params[:order]&.strip 11 | @order = 'updated' unless %w(updated newest views alpha).include?(@order) 12 | @show_up_for_review = can_edit? && !request.path.include?(up_for_review_path) 13 | 14 | articles = if request.path.include? up_for_review_path 15 | Article.up_for_review(order: @order&.to_sym) 16 | elsif @search_term.present? 17 | Article.search(term: @search_term, category_id: @category&.id, order: @order&.to_sym) 18 | elsif @category.present? 19 | @category.articles_in_branch(order: @order&.to_sym) 20 | else 21 | Article.uncategorized(order: @order&.to_sym) 22 | end 23 | 24 | @articles = paginate(articles) 25 | end 26 | 27 | def new 28 | @category = Category.new 29 | @parent_category_code = params[:parent_category_code] 30 | end 31 | 32 | def edit 33 | return redirect_to root_path if @category.blank? 34 | @parent_category_code = @category.parent&.code 35 | end 36 | 37 | def create 38 | @category = Category.new(name: params[:name], parent: Category.find_by(code: params[:parent_category_code])) 39 | 40 | if @category.save 41 | flash[:green] = 'Category was created' 42 | redirect_to article_index_path(@category.code) 43 | else 44 | flash.now[:red] = 'Category could not be created' 45 | @parent_category_code = params[:parent_category_code] 46 | render :new 47 | end 48 | end 49 | 50 | def update 51 | return redirect_to root_path if @category.blank? 52 | 53 | if @category.update(name: params[:name], parent: Category.find_by(code: params[:parent_category_code])) 54 | flash[:green] = 'Category was updated' 55 | redirect_to article_index_path(@category.code) 56 | else 57 | flash.now[:red] = 'Category could not be updated' 58 | @parent_category_code = params[:parent_category_code] 59 | render :edit 60 | end 61 | end 62 | 63 | def destroy 64 | Category.find(params[:id].to_i).destroy! 65 | 66 | flash[:green] = 'Category was deleted' 67 | render json: {}, layout: false 68 | end 69 | 70 | private 71 | 72 | def fetch_category 73 | @category = Category.find_by(code: params[:cat_code].to_s.strip) 74 | @category = Category.find_by(id: params[:id].to_i) if @category.blank? 75 | end 76 | end 77 | end 78 | -------------------------------------------------------------------------------- /app/controllers/dokno/pagination_concern.rb: -------------------------------------------------------------------------------- 1 | module Dokno 2 | module PaginationConcern 3 | extend ActiveSupport::Concern 4 | 5 | def paginate(records, max_per_page: 10) 6 | @page = params[:page].to_i 7 | @total_records = records.size 8 | @total_pages = (@total_records.to_f / max_per_page).ceil 9 | @total_pages = 1 unless @total_pages.positive? 10 | @page = 1 unless @page.positive? && @page <= @total_pages 11 | 12 | records.offset((@page - 1) * max_per_page).limit(max_per_page) 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /app/controllers/dokno/user_concern.rb: -------------------------------------------------------------------------------- 1 | module Dokno 2 | module UserConcern 3 | extend ActiveSupport::Concern 4 | 5 | included do 6 | helper_method :user, :username, :can_edit? 7 | end 8 | 9 | def user 10 | # Attempt to eval the currently signed in 'user' object from the host app 11 | proc { 12 | $safe = 1 13 | eval sanitized_user_obj_string 14 | }.call 15 | 16 | rescue NameError => _e 17 | nil 18 | end 19 | 20 | def sanitized_user_obj_string 21 | Dokno.config.app_user_object.to_s.split(/\b/).first 22 | end 23 | 24 | def username 25 | user&.send(Dokno.config.app_user_name_method.to_sym).to_s 26 | end 27 | 28 | def can_edit? 29 | # Allow editing by default if host app user object is not configured 30 | return true unless sanitized_user_obj_string.present? 31 | return false unless user&.respond_to? Dokno.config.app_user_auth_method.to_sym 32 | 33 | user.send(Dokno.config.app_user_auth_method.to_sym) 34 | end 35 | 36 | def authorize 37 | return redirect_to root_path unless can_edit? 38 | end 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /app/helpers/dokno/application_helper.rb: -------------------------------------------------------------------------------- 1 | module Dokno 2 | module ApplicationHelper 3 | def dokno_article_link(link_text = nil, slug: nil) 4 | return "Dokno article slug is required" unless slug.present? 5 | 6 | # Check for slug, including deprecated slugs 7 | article = Article.find_by(slug: slug.strip) 8 | article = ArticleSlug.find_by(slug: slug.strip)&.article if article.blank? 9 | 10 | return "Dokno article slug '#{slug}' not found" if article.blank? 11 | 12 | %Q(#{link_text.presence || article.title}).html_safe 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /app/models/dokno/application_record.rb: -------------------------------------------------------------------------------- 1 | module Dokno 2 | class ApplicationRecord < ActiveRecord::Base 3 | self.abstract_class = true 4 | 5 | include Engine.routes.url_helpers 6 | include ActionView::Helpers::DateHelper 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /app/models/dokno/article.rb: -------------------------------------------------------------------------------- 1 | require 'diffy' 2 | require 'redcarpet' 3 | 4 | module Dokno 5 | class Article < ApplicationRecord 6 | has_and_belongs_to_many :categories 7 | has_many :logs, dependent: :destroy 8 | has_many :article_slugs, dependent: :destroy 9 | 10 | validates :slug, :title, presence: true 11 | validates :slug, length: { in: 2..20 } 12 | validates :title, length: { in: 5..255 } 13 | validate :unique_slug_check 14 | 15 | attr_accessor :editor_username, :reset_review_date, :review_notes 16 | 17 | before_save :set_review_date, if: :should_set_review_date? 18 | before_save :log_changes 19 | before_save :track_slug 20 | 21 | scope :active, -> { where(active: true) } 22 | scope :alpha_order, -> { order(active: :desc, starred: :desc, title: :asc) } 23 | scope :views_order, -> { order(active: :desc, starred: :desc, views: :desc, title: :asc) } 24 | scope :newest_order, -> { order(active: :desc, starred: :desc, created_at: :desc, title: :asc) } 25 | scope :updated_order, -> { order(active: :desc, starred: :desc, updated_at: :desc, title: :asc) } 26 | 27 | MARKDOWN_PARSER = Redcarpet::Markdown.new(Redcarpet::Render::HTML, autolink: true, tables: true) 28 | 29 | def review_due_at 30 | super || (Date.today + 30.years) 31 | end 32 | 33 | def markdown 34 | super || '' 35 | end 36 | 37 | def reading_time 38 | minutes_decimal = (("#{summary} #{markdown}".squish.scan(/[\w-]+/).size) / 200.0) 39 | approx_minutes = minutes_decimal.ceil 40 | return '' unless approx_minutes > 1 41 | 42 | "~ #{approx_minutes} minutes" 43 | end 44 | 45 | def review_due_days 46 | (review_due_at.to_date - Date.today).to_i 47 | end 48 | 49 | def up_for_review? 50 | active && review_due_days <= Dokno.config.article_review_prompt_days 51 | end 52 | 53 | def review_due_days_string 54 | if review_due_days.positive? 55 | "This article is up for an accuracy / relevance review in #{review_due_days} #{'day'.pluralize(review_due_days)}" 56 | elsif review_due_days.negative? 57 | "This article was up for an accuracy / relevance review #{review_due_days.abs} #{'day'.pluralize(review_due_days)} ago" 58 | else 59 | "This article is up for an accuracy / relevance review today" 60 | end 61 | end 62 | 63 | def markdown_parsed 64 | self.class.parse_markdown markdown 65 | end 66 | 67 | # Breadcrumbs for all associated categories; limits to sub-categories if in the context of a category 68 | def category_name_list(context_category_id: nil, order: nil, search_term: nil) 69 | return '' if categories.blank? 70 | 71 | names = Category 72 | .joins(articles_dokno_categories: :article) 73 | .where(dokno_articles_categories: { article_id: id }) 74 | .all 75 | .map do |category| 76 | next if context_category_id == category.id 77 | 78 | "#{category.name}" 80 | end.compact 81 | 82 | return '' if names.blank? 83 | 84 | list = (context_category_id.present? ? 'In other category' : 'Category').pluralize(names.length) 85 | list += ': ' + names.to_sentence 86 | list.html_safe 87 | end 88 | 89 | # Hash returned for the ajax-fetched slide-in article panel for the host app 90 | def host_panel_hash 91 | footer = %Q( 92 |

Print / email / edit / delete

93 |

#{categories.present? ? category_name_list : 'Uncategorized'}

94 |

Knowledgebase slug : #{slug}

95 |

Last updated : #{time_ago_in_words(updated_at)} ago

96 | ) 97 | 98 | footer += "

Contributors : #{contributors}

" if contributors.present? 99 | 100 | title_markup = %Q( 101 | #{title} 102 | ) 103 | 104 | unless active 105 | title_markup = title_markup.prepend( 106 | %Q( 107 |
108 | This article is no longer active 109 |
110 | ) 111 | ) 112 | end 113 | 114 | { 115 | title: title_markup, 116 | id: id, 117 | slug: slug, 118 | summary: summary.presence || (markdown_parsed.present? ? '' : 'No content'), 119 | markdown: markdown_parsed, 120 | footer: footer 121 | } 122 | end 123 | 124 | def permalink(base_url) 125 | "#{base_url}#{article_path(slug)}" 126 | end 127 | 128 | def contributors 129 | logs 130 | .where('meta LIKE ? OR meta LIKE ?', '%Markdown%', '%Summary%') 131 | .pluck(:username) 132 | .reject(&:blank?) 133 | .uniq 134 | .sort 135 | .to_sentence 136 | end 137 | 138 | # All articles up for review 139 | def self.up_for_review(order: :updated) 140 | records = Article 141 | .includes(:categories_dokno_articles, :categories) 142 | .where(active: true) 143 | .where('review_due_at <= ?', Date.today + Dokno.config.article_review_prompt_days) 144 | 145 | apply_sort(records, order: order) 146 | end 147 | 148 | # All uncategorized Articles 149 | def self.uncategorized(order: :updated) 150 | records = Article 151 | .includes(:categories_dokno_articles, :categories) 152 | .left_joins(:categories) 153 | .where(dokno_categories: { id: nil }) 154 | 155 | apply_sort(records, order: order) 156 | end 157 | 158 | def self.search(term:, category_id: nil, order: :updated) 159 | records = where( 160 | 'LOWER(title) LIKE :search_term OR '\ 161 | 'LOWER(summary) LIKE :search_term OR '\ 162 | 'LOWER(markdown) LIKE :search_term OR '\ 163 | 'LOWER(slug) LIKE :search_term', 164 | search_term: "%#{term.downcase}%" 165 | ) 166 | .includes(:categories_dokno_articles) 167 | .includes(:categories) 168 | 169 | records = apply_sort(records, order: order) 170 | 171 | return records unless category_id.present? 172 | 173 | # Scope to the context category and its children 174 | records 175 | .joins(:categories) 176 | .where( 177 | dokno_categories: { 178 | id: Category.branch(parent_category_id: category_id).pluck(:id) 179 | } 180 | ) 181 | end 182 | 183 | def self.parse_markdown(content) 184 | ActionController::Base.helpers.sanitize( 185 | MARKDOWN_PARSER.render(content), 186 | tags: Dokno.config.tag_whitelist, 187 | attributes: Dokno.config.attr_whitelist 188 | ) 189 | end 190 | 191 | def self.template 192 | template_file = File.join(Rails.root, 'config', 'dokno_template.md') 193 | return unless File.exist?(template_file) 194 | 195 | File.read(template_file).to_s 196 | end 197 | 198 | def self.apply_sort(records, order: :updated) 199 | order_scope = "#{order}_order" 200 | return records unless records.respond_to? order_scope 201 | 202 | records.send(order_scope.to_sym) 203 | end 204 | 205 | private 206 | 207 | # Ensure there isn't another Article with the same slug 208 | def unique_slug_check 209 | slug_used = self.class.where(slug: slug&.strip).where.not(id: id).exists? 210 | slug_used ||= ArticleSlug.where(slug: slug&.strip).exists? 211 | return unless slug_used 212 | 213 | errors.add(:slug, "must be unique, #{slug} has already been used") 214 | end 215 | 216 | def track_slug 217 | return unless slug_changed? 218 | 219 | old_slug = changes['slug'].first 220 | ArticleSlug.where(slug: old_slug&.strip, article_id: id).first_or_create 221 | end 222 | 223 | def log_changes 224 | return if changes.blank? && !reset_review_date 225 | 226 | meta_changes = changes.with_indifferent_access.slice(:slug, :title, :active) 227 | content_changes = changes.with_indifferent_access.slice(:summary, :markdown) 228 | 229 | meta = [] 230 | meta_changes.each_pair do |field, values| 231 | action = persisted? ? "changed from '#{values.first}' to" : "entered as" 232 | meta << "#{field.capitalize} #{action} '#{values.last}'" 233 | end 234 | 235 | content = { before: '', after: '' } 236 | content_changes.each_pair do |field, values| 237 | meta << "#{field.capitalize} was #{persisted? ? 'changed' : 'entered'}" 238 | content[:before] += values.first.to_s + ' ' 239 | content[:after] += values.last.to_s + ' ' 240 | end 241 | 242 | if meta.present? 243 | diff = Diffy::SplitDiff.new(content[:before].squish, content[:after].squish, format: :html) 244 | logs << Log.new(username: editor_username, meta: meta.to_sentence, diff_left: diff.left, diff_right: diff.right) 245 | end 246 | 247 | # Reviewed for accuracy / relevance? 248 | return unless reset_review_date 249 | 250 | review_log = "Reviewed for accuracy / relevance. Next review date reset to #{review_due_at.to_date}." 251 | review_log += " Review notes: #{review_notes.squish}" if review_notes.present? 252 | logs << Log.new(username: editor_username, meta: review_log) 253 | end 254 | 255 | def should_set_review_date? 256 | # User requested a reset or it's a new article w/out a review due date 257 | reset_review_date || (!persisted? && review_due_at.blank?) 258 | end 259 | 260 | def set_review_date 261 | self.review_due_at = Date.today + Dokno.config.article_review_period 262 | end 263 | end 264 | end 265 | -------------------------------------------------------------------------------- /app/models/dokno/article_slug.rb: -------------------------------------------------------------------------------- 1 | module Dokno 2 | class ArticleSlug < ApplicationRecord 3 | belongs_to :article 4 | 5 | validates :slug, presence: true 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /app/models/dokno/category.rb: -------------------------------------------------------------------------------- 1 | module Dokno 2 | class Category < ApplicationRecord 3 | belongs_to :parent, class_name: 'Dokno::Category', primary_key: 'id', foreign_key: 'category_id', inverse_of: :children, optional: true 4 | has_many :children, class_name: 'Dokno::Category', primary_key: 'id', foreign_key: 'category_id', inverse_of: :parent, dependent: :nullify 5 | 6 | has_and_belongs_to_many :articles 7 | 8 | before_validation :set_code 9 | validates :name, :code, presence: true, uniqueness: true 10 | validate :circular_parent_check 11 | 12 | scope :alpha_order, -> { order(:name) } 13 | 14 | def breadcrumb(**args) 15 | crumbs = [(category_link(self, args) unless args[:hide_self])] 16 | parent_category_id = category_id 17 | 18 | loop do 19 | break if parent_category_id.blank? 20 | 21 | parent_category = Category.find(parent_category_id) 22 | crumbs.prepend category_link(parent_category, args) 23 | parent_category_id = parent_category.category_id 24 | end 25 | 26 | crumbs.compact.join("  >  ").html_safe 27 | end 28 | 29 | def category_link(category, args={}) 30 | %(#{category.name}) 31 | end 32 | 33 | # All Articles in the Category, including all child Categories 34 | def articles_in_branch(order: :updated) 35 | records = Article 36 | .includes(:categories_dokno_articles, :categories) 37 | .joins(:categories) 38 | .where(dokno_categories: { id: self.class.branch(parent_category_id: id).pluck(:id) }) 39 | 40 | Article.apply_sort(records, order: order) 41 | end 42 | 43 | def branch 44 | self.class.branch(parent_category_id: id) 45 | end 46 | 47 | # Used to invalidate the fragment cache of the hierarchical category select options 48 | def self.cache_key 49 | [maximum(:updated_at), Article.maximum(:updated_at)].compact.max 50 | end 51 | 52 | # The given Category and all child Categories. Useful for filtering associated articles. 53 | def self.branch(parent_category_id:, at_top: true) 54 | return if parent_category_id.blank? 55 | 56 | categories = [] 57 | parent_category = find(parent_category_id) 58 | child_categories = parent_category.children.to_a 59 | 60 | child_categories.each do |child_category| 61 | categories << child_category << branch(parent_category_id: child_category.id, at_top: false) 62 | end 63 | 64 | categories.prepend parent_category if at_top 65 | categories.flatten 66 | end 67 | 68 | def self.select_option_markup(selected_category_codes: nil, context_category: nil, level: 0) 69 | return '' if level.positive? && context_category.blank? 70 | 71 | options = [] 72 | level_categories = where(category_id: context_category&.id).alpha_order 73 | level_categories.each do |category| 74 | options << option_markup(category: category, selected_category_codes: selected_category_codes, level: level) 75 | options << select_option_markup(selected_category_codes: selected_category_codes, context_category: category, level: (level + 1)) 76 | end 77 | 78 | options.join 79 | end 80 | 81 | private 82 | 83 | def self.option_markup(category:, selected_category_codes:, level: 0) 84 | selected = selected_category_codes&.include?(category.code) 85 | article_count = category.articles_in_branch.size 86 | %() 87 | end 88 | 89 | # Never allow setting of parent to self 90 | def circular_parent_check 91 | return unless persisted? && id.to_i == category_id.to_i 92 | 93 | errors.add(:category_id, "can't set parent to self") 94 | end 95 | 96 | def set_code 97 | return unless name.present? 98 | 99 | self.code = name.downcase.strip.gsub(' ', '-').gsub(/[^\w-]/, '') 100 | end 101 | end 102 | end 103 | -------------------------------------------------------------------------------- /app/models/dokno/log.rb: -------------------------------------------------------------------------------- 1 | module Dokno 2 | class Log < ApplicationRecord 3 | belongs_to :article 4 | 5 | default_scope { order(created_at: :desc) } 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /app/views/dokno/_article_formatting.html.erb: -------------------------------------------------------------------------------- 1 | 89 | -------------------------------------------------------------------------------- /app/views/dokno/_article_panel.html.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | <%= render 'dokno/reset_formatting' %> 7 | 8 | 9 | <%= render 'dokno/panel_formatting' %> 10 | 11 | 12 | <%= render 'dokno/article_formatting' %> 13 | 14 | 15 |
16 |
17 |
18 |
19 | 20 |
21 | 22 | 23 | 111 | -------------------------------------------------------------------------------- /app/views/dokno/_panel_formatting.html.erb: -------------------------------------------------------------------------------- 1 | 86 | -------------------------------------------------------------------------------- /app/views/dokno/_reset_formatting.html.erb: -------------------------------------------------------------------------------- 1 | 56 | -------------------------------------------------------------------------------- /app/views/dokno/articles/_article_form.html.erb: -------------------------------------------------------------------------------- 1 | <%= render 'dokno/article_formatting' %> 2 | 3 |
4 | 5 |
<%= article.persisted? ? 'Edit' : 'New' %> Article
6 |

<%= article.title %>

7 |
8 | 9 | <% if article.up_for_review? %> 10 |
11 |

Review for Accuracy / Relevance

12 | 13 |
14 |
15 |
16 | 17 | /> 18 |
19 |
20 | <%= article.review_due_days_string %>. 21 | Check the box above to mark this article as reviewed and reset the next review date to <%= "#{Date.today + Dokno.config.article_review_period}" %> 22 |
23 |
24 |
25 |
26 | 27 |
28 |
29 |
30 | <% end %> 31 | 32 |
33 |
34 | 35 |
Unique identifier for this article (required) (2-20 alphanumeric chars)
36 |
37 | 38 | <% if Dokno::Category.exists? %> 39 |
40 |
41 | 45 |
46 | If applicable, select one or more categories to which this article will belong (optional). CTRL/CMD to select multiple. 47 | Articles will be automatically included in parent categories. Uncategorized articles 48 | are displayed on the landing page. 49 |
50 |
51 | <% end %> 52 | 53 |
54 | 55 |
56 |
57 | 58 |
Descriptive article title (required) (5-255 alphanumeric chars)
59 |
60 | 61 |
62 |
63 | 64 |
Brief summary of the described topic (text only)
65 |
66 | 67 |
68 |
69 | 70 | 71 | Preview 72 |
73 | 74 | 75 |
Detailed documentation of the described topic. Basic HTML & markdown OK.
76 |
77 | 78 |
79 |
80 | 81 | 82 | /> 83 |
84 |
85 | Starred articles are always listed first when browsing. 86 |
87 |
88 | 89 |
90 | 91 |
92 | Cancel 93 | 94 |
95 | 96 |
97 | 98 | 102 | -------------------------------------------------------------------------------- /app/views/dokno/articles/edit.html.erb: -------------------------------------------------------------------------------- 1 | <%= form_with(model: @article, local: true) do |form| %> 2 | <% if @article.errors.any? %> 3 | <%= render 'partials/form_errors', errors: @article.errors %> 4 | <% end %> 5 | 6 | <%= render 'article_form', article: @article %> 7 | <% end %> -------------------------------------------------------------------------------- /app/views/dokno/articles/new.html.erb: -------------------------------------------------------------------------------- 1 | <%= form_with(url: articles_path) do %> 2 | <% if @article.errors.any? %> 3 | <%= render 'partials/form_errors', errors: @article.errors %> 4 | <% end %> 5 | 6 | <%= render 'article_form', article: @article %> 7 | <% end %> -------------------------------------------------------------------------------- /app/views/dokno/articles/show.html.erb: -------------------------------------------------------------------------------- 1 | <%= render 'dokno/article_formatting' %> 2 | 3 | <% if @category.present? %> 4 |
5 |
6 | <% if request.referrer&.include? article_index_path %> 7 | 8 | <% end %> 9 | Under <%= @category.breadcrumb(search_term: @search_term, order: @order) %> 10 |
11 |
12 | <% end %> 13 | 14 | <%= render 'partials/category_header' %> 15 | 16 |

17 | 18 |
19 |
20 |
21 |

22 | <%= @article.title %> 23 | <% if @article.starred %><% end %> 24 |

25 | 26 |
27 |
28 | 29 | 30 | 31 |
32 | 33 |
34 |
35 |
36 | Last updated:
37 | <%= time_ago_in_words @article.updated_at %> ago 38 | <% if (editor_username = @article.logs.first&.username).present? %> 39 | by <%= editor_username %> 40 | <% end %> 41 |
42 |
43 | 44 |
45 |
46 |
47 | Views:
48 | <%= number_with_delimiter(@article.views, delimiter: ',') %> 49 |
50 |
51 | 52 | <% if @article.contributors.present? %> 53 |
54 |
55 |
56 | Contributors:
57 | <%= @article.contributors %> 58 |
59 |
60 | <% end %> 61 | 62 | <% if @article.reading_time.present? %> 63 |
64 |
65 |
66 | Reading time:
67 | <%= @article.reading_time %> 68 |
69 |
70 | <% end %> 71 | 72 |
73 |
74 | 78 |
79 | 80 | <% if can_edit? %> 81 |
82 |
83 |
84 | Unique slug:
85 | <%= @article.slug %> 86 | 87 | <% if (old_slugs = @article.article_slugs).present? %> 88 |
89 | Editor note:
90 | This article is still accessible via previous 91 | <%= 'slug'.pluralize(old_slugs.count) %> 92 | <%= old_slugs.map(&:slug).reject { |slug| slug == @article.slug }.to_sentence %> 93 |
94 | <% end %> 95 |
96 |
97 | <% end %> 98 | 99 | <% if (category_name_list = @article.category_name_list(context_category_id: @category&.id)).present? %> 100 |
101 |
102 |
103 | <%= category_name_list.sub(':', ':
').html_safe %> 104 |
105 |
106 | <% end %> 107 |
108 |
109 |
110 | <% if @article.summary.present? %> 111 |
<%= simple_format @article.summary %>
112 | <% end %> 113 | 114 | <% if @article.markdown.present? %> 115 |
116 | <%= @article.markdown_parsed %> 117 |
118 | <% end %> 119 | 120 | <% if @article.summary.blank? && @article.markdown.blank? %> 121 |
No content
122 | <% end %> 123 |
124 |
125 |
126 | 127 | <% if can_edit? %> 128 |
129 |
130 |
131 | 132 | 133 | <% if @article.active %> 134 | 135 | <% else %> 136 | 137 | <% end %> 138 | 139 |
140 |
141 | <% end %> 142 | -------------------------------------------------------------------------------- /app/views/dokno/categories/_category_form.html.erb: -------------------------------------------------------------------------------- 1 |
2 | 3 |
<%= category.persisted? ? 'Edit' : 'New' %> Category
4 |
5 | 6 |
7 |
8 | 9 |
Unique name for this category (required)
10 |
11 | 12 | <% if Dokno::Category.exists? %> 13 |
14 |
15 | 16 | 25 | 26 |
27 | The existing category to which this category belongs. 28 | <% if (article_count = category.articles.count).positive? %> 29 | There <%= article_count > 1 ? "are #{article_count} articles" : 'is an article' %> currently in this category. 30 | Changing the parent category will move <%= article_count > 1 ? 'them' : 'it' %> as well. 31 | <% end %> 32 |
33 |
34 | <% end %> 35 | 36 |
37 | 38 | <% if category.persisted? %> 39 | 40 | <% end %> 41 | ">Cancel 42 |
43 |
44 | 45 | 50 | -------------------------------------------------------------------------------- /app/views/dokno/categories/edit.html.erb: -------------------------------------------------------------------------------- 1 | <%= form_with(model: @category, local: true) do |form| %> 2 | <% if @category.errors.any? %> 3 | <%= render 'partials/form_errors', errors: @category.errors %> 4 | <% end %> 5 | 6 | <%= render 'category_form', category: @category %> 7 | <% end %> -------------------------------------------------------------------------------- /app/views/dokno/categories/index.html.erb: -------------------------------------------------------------------------------- 1 | <% if !current_page?(up_for_review_path) && (@category.blank? || @search_term.present?) %> 2 |
3 | <% if @search_term.present? %> 4 |
5 | <%= @total_records.positive? ? "#{@total_records} #{'article'.pluralize(@total_records)}" : 'No articles' %> 6 | found containing the search term 7 |
<%= @search_term %>
8 |
9 | <% else %> 10 |
11 | Browse or search 12 | <% if (article_count = Dokno::Article.count) > 1 %> 13 | <%= number_with_delimiter(article_count, delimiter: ',') %> articles in 14 | <% end %> 15 | the 16 |
17 |
<%= Dokno.config.app_name %> knowledgebase
18 | <% end %> 19 |
20 | <% end %> 21 | 22 | <% if @category&.parent.present? %> 23 |
24 |
Under <%= @category.breadcrumb(search_term: @search_term, order: @order, hide_self: true) %>
25 |
26 | <% end %> 27 | 28 | <% if !current_page?(up_for_review_path) && (Dokno::Category.exists? || Dokno::Article.exists?) %> 29 | <%= render 'partials/category_header' %> 30 | <% end %> 31 | 32 | <% if @articles.blank? %> 33 | 34 |
35 | <% if Dokno::Category.exists? %> 36 | <% if @search_term.present? %> 37 | No articles found <% if @category.present? %>in this category<% end %> matching the given search criteria 38 | <% elsif @category.present? %> 39 | No articles found in this category 40 | <% else %> 41 | No uncategorized articles 42 | <% end %> 43 | <% elsif can_edit? %> 44 | Add your first category 45 | <% end %> 46 |
47 | 48 | <% else %> 49 | 50 |
51 |
52 | <%= render 'partials/pagination' %> 53 |
54 |
55 | 56 | Updated 57 | Newest 58 | Views 59 | Title 60 |
61 |
62 | 63 |
64 | <% @articles.each do |article| %> 65 |
66 |
67 |
68 |
69 | 72 |
73 |
74 |
75 |
<%= article.summary.presence || 'No summary provided' %>
76 | 77 |
78 | <%= article.category_name_list(context_category_id: @category&.id, order: @order, search_term: @search_term) %> 79 |
80 | 81 | <% unless @order == 'alpha' %> 82 |
83 | <% if @order == 'views' %> 84 |
This article was viewed <%= number_with_delimiter(article.views, delimiter: ',') %> <%= 'time'.pluralize(article.views) %>
85 | <% elsif @order == 'updated' %> 86 |
This article was last updated <%= time_ago_in_words article.updated_at %> ago
87 | <% elsif @order == 'newest' %> 88 |
This article was added <%= time_ago_in_words article.created_at %> ago
89 | <% end %> 90 |
91 | <% end %> 92 | 93 | <% if !article.active %> 94 |
95 | This article is no longer active 96 |
97 | <% end %> 98 | 99 | <% if article.up_for_review? %> 100 |
101 | <%= article.review_due_days_string %> 102 |
103 | <% end %> 104 | 105 |
106 |
107 | <% end %> 108 |
109 | 110 |
111 |
112 | <%= render 'partials/pagination' %> 113 |
114 |
115 |
116 | <% end %> 117 | 118 | <% if @search_term.present? && @articles.blank? %> 119 | 120 | <% end %> 121 | -------------------------------------------------------------------------------- /app/views/dokno/categories/new.html.erb: -------------------------------------------------------------------------------- 1 | <%= form_with(url: categories_path) do %> 2 | <% if @category.errors.any? %> 3 | <%= render 'partials/form_errors', errors: @category.errors %> 4 | <% end %> 5 | 6 | <%= render 'category_form', category: @category %> 7 | <% end %> -------------------------------------------------------------------------------- /app/views/layouts/dokno/application.html.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | <%= Dokno.config.app_name %> KNOWLEDGEBASE 5 | 6 | 7 | <%= csrf_meta_tags %> 8 | <%= csp_meta_tag %> 9 | 10 | <%= stylesheet_link_tag "dokno/application", media: "all" %> 11 | <%= javascript_include_tag 'dokno/application' %> 12 | 13 | 16 | 17 | 18 | 19 | 20 | 45 | 46 | 47 | <% flash.each do |type, msg| %> 48 |
49 | 50 | <%= sanitize(msg, tags: %w[a], attributes: %w[href class]) %> 51 |
52 | <% end %> 53 | 54 | 55 |
56 | 59 |
60 | 61 | 62 |
63 | <% if @article.present? && action_name == 'show' %> 64 |
65 | <%= render 'partials/logs', category: @category, article: @article %> 66 |
67 | <% end %> 68 | 69 | <% if @show_up_for_review && (up_for_review_count = Dokno::Article.up_for_review.count).positive? %> 70 |
71 |
72 |
73 |
74 | 75 | There <%= "#{up_for_review_count == 1 ? 'is' : 'are'} #{up_for_review_count}" %> <%= 'article'.pluralize(up_for_review_count) %> up for accuracy / relevance review 76 |
77 |
78 |
79 |
80 | <% end %> 81 | 82 |
83 |
84 |
85 | 86 | dokno 87 |
88 |
89 | <% if user.present? %> 90 | 91 | <%= username %> 92 | (<%= can_edit? ? 'Editor' : 'Read Only' %>) 93 | 94 | <% end %> 95 | 96 | <%= Dokno.config.app_name %> 97 |
98 |
99 |
100 |
101 | 102 | <%= javascript_include_tag 'init' %> 103 | 104 | <% if @search_term.present? %> 105 | 106 | <% end %> 107 | 108 | 109 | -------------------------------------------------------------------------------- /app/views/partials/_category_header.html.erb: -------------------------------------------------------------------------------- 1 |
2 | <% if Dokno::Category.exists? %> 3 |
4 | 13 |
14 | 15 | 19 | <% end %> 20 | 21 | <% if Dokno::Article.exists? %> 22 |
23 | 24 | 25 | <% if @category.present? %>
/
<% end %> 26 |
27 | <% end %> 28 |
29 | 30 | 31 | -------------------------------------------------------------------------------- /app/views/partials/_form_errors.html.erb: -------------------------------------------------------------------------------- 1 |
2 |

3 | There 4 | <% if errors.count == 1 %> 5 | is a problem 6 | <% else %> 7 | are <%= pluralize(errors.count, "problem") %> 8 | <% end %> 9 | that must be resolved before saving: 10 |

11 | 12 |
    13 | <% errors.full_messages.each do |message| %> 14 |
  • <%= message %>
  • 15 | <% end %> 16 |
17 |
18 | -------------------------------------------------------------------------------- /app/views/partials/_logs.html.erb: -------------------------------------------------------------------------------- 1 | <% if article&.logs.present? %> 2 |
3 |
4 |
5 | Change history for this article 6 | 7 |
8 | 9 |
10 |
11 | 12 | 52 |
53 |
54 | <% end %> 55 | -------------------------------------------------------------------------------- /app/views/partials/_pagination.html.erb: -------------------------------------------------------------------------------- 1 | <% if @total_pages > 1 %> 2 | 3 | <% if @page > 1 %> 4 | 5 | <% end %> 6 | 7 | Page 8 | 9 | <%= form_with(url: article_index_path(@category&.code), method: :get, class: 'inline') do %> 10 | 11 | 12 | 13 | 14 | of 15 | <%= @total_pages %> 16 | <% end %> 17 | 18 | <% if @page < @total_pages %> 19 | 20 | <% end %> 21 | 22 | <% end %> 23 | 24 | 25 | <%= @total_records %> 26 | <%= 'uncategorized' if !current_page?(up_for_review_path) && @category.blank? && @search_term.blank? %> 27 | <%= 'article'.pluralize(@total_records) %> 28 | <%= 'up for review' if current_page?(up_for_review_path) %> 29 | 30 | <% if @search_term.present? %> 31 | containing <%= @search_term %> 32 | <% end %> 33 | 34 | <% if @category.present? %> 35 | in <%= "#{(children_count = @category.branch.count) == 1 ? 'this' : children_count} #{'category'.pluralize(children_count)}" %> 36 | <% end %> 37 | 38 | -------------------------------------------------------------------------------- /bin/rails: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # This command will automatically be run when you run "rails" with Rails gems 3 | # installed from the root of your application. 4 | 5 | ENGINE_ROOT = File.expand_path('..', __dir__) 6 | ENGINE_PATH = File.expand_path('../lib/dokno/engine', __dir__) 7 | APP_PATH = File.expand_path('../spec/dummy/config/application', __dir__) 8 | 9 | # Set up gems listed in the Gemfile. 10 | ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../Gemfile', __dir__) 11 | require 'bundler/setup' if File.exist?(ENV['BUNDLE_GEMFILE']) 12 | 13 | require "rails" 14 | # Pick the frameworks you want: 15 | require "active_model/railtie" 16 | # require "active_job/railtie" 17 | require "active_record/railtie" 18 | # require "active_storage/engine" 19 | require "action_controller/railtie" 20 | # require "action_mailer/railtie" 21 | require "action_view/railtie" 22 | # require "action_cable/engine" 23 | require "sprockets/railtie" 24 | # require "rails/test_unit/railtie" 25 | require 'rails/engine/commands' 26 | -------------------------------------------------------------------------------- /config/routes.rb: -------------------------------------------------------------------------------- 1 | Dokno::Engine.routes.draw do 2 | resources :categories, except: [:show] 3 | resources :articles 4 | 5 | get '/(:cat_code)', to: 'categories#index', as: :article_index 6 | get '/up_for_review', to: 'categories#index', as: :up_for_review 7 | get 'article_panel/(:slug)', to: 'articles#panel', as: :panel 8 | post 'article_preview', to: 'articles#preview', as: :preview 9 | post 'article_status', to: 'articles#status', as: :status 10 | root 'categories#index' 11 | end 12 | -------------------------------------------------------------------------------- /db/migrate/20201203190330_baseline.rb: -------------------------------------------------------------------------------- 1 | class Baseline < ActiveRecord::Migration[6.0] 2 | def change 3 | create_table "dokno_article_slugs", force: :cascade do |t| 4 | t.string "slug", null: false 5 | t.bigint "article_id" 6 | t.datetime "created_at", precision: 6, null: false 7 | t.datetime "updated_at", precision: 6, null: false 8 | t.index ["article_id"], name: "index_dokno_article_slugs_on_article_id" 9 | t.index ["slug"], name: "index_dokno_article_slugs_on_slug", unique: true 10 | end 11 | 12 | create_table "dokno_articles", force: :cascade do |t| 13 | t.string "slug" 14 | t.string "title" 15 | t.text "markdown" 16 | t.text "summary" 17 | t.boolean "active", default: true 18 | t.bigint "views", default: 0 19 | t.datetime "last_viewed_at" 20 | t.datetime "created_at", precision: 6, null: false 21 | t.datetime "updated_at", precision: 6, null: false 22 | t.index ["slug"], name: "index_dokno_articles_on_slug", unique: true 23 | end 24 | 25 | create_table "dokno_articles_categories", id: false, force: :cascade do |t| 26 | t.bigint "article_id" 27 | t.bigint "category_id" 28 | t.index ["article_id", "category_id"], name: "index_dokno_articles_categories_on_article_id_and_category_id", unique: true 29 | t.index ["article_id"], name: "index_dokno_articles_categories_on_article_id" 30 | t.index ["category_id"], name: "index_dokno_articles_categories_on_category_id" 31 | end 32 | 33 | create_table "dokno_categories", force: :cascade do |t| 34 | t.string "name" 35 | t.bigint "category_id" 36 | t.string "code", null: false 37 | t.datetime "created_at", precision: 6, null: false 38 | t.datetime "updated_at", precision: 6, null: false 39 | t.index ["category_id"], name: "index_dokno_categories_on_category_id" 40 | t.index ["code"], name: "index_dokno_categories_on_code", unique: true 41 | t.index ["name"], name: "index_dokno_categories_on_name", unique: true 42 | end 43 | 44 | create_table "dokno_logs", force: :cascade do |t| 45 | t.bigint "article_id" 46 | t.string "username" 47 | t.text "meta" 48 | t.text "diff_left" 49 | t.text "diff_right" 50 | t.datetime "created_at", precision: 6, null: false 51 | t.datetime "updated_at", precision: 6, null: false 52 | t.index ["article_id"], name: "index_dokno_logs_on_article_id" 53 | t.index ["username"], name: "index_dokno_logs_on_username" 54 | end 55 | 56 | add_foreign_key "dokno_article_slugs", "dokno_articles", column: "article_id" 57 | add_foreign_key "dokno_articles_categories", "dokno_articles", column: "article_id" 58 | add_foreign_key "dokno_articles_categories", "dokno_categories", column: "category_id" 59 | add_foreign_key "dokno_categories", "dokno_categories", column: "category_id" 60 | add_foreign_key "dokno_logs", "dokno_articles", column: "article_id" 61 | end 62 | end 63 | -------------------------------------------------------------------------------- /db/migrate/20201211192306_add_review_due_at_to_articles.rb: -------------------------------------------------------------------------------- 1 | class AddReviewDueAtToArticles < ActiveRecord::Migration[6.0] 2 | def change 3 | add_column :dokno_articles, :review_due_at, :datetime 4 | add_index :dokno_articles, :review_due_at 5 | end 6 | end 7 | -------------------------------------------------------------------------------- /db/migrate/20201213165700_add_starred_to_article.rb: -------------------------------------------------------------------------------- 1 | class AddStarredToArticle < ActiveRecord::Migration[6.0] 2 | def change 3 | add_column :dokno_articles, :starred, :boolean, default: false 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /dokno.gemspec: -------------------------------------------------------------------------------- 1 | $:.push File.expand_path("lib", __dir__) 2 | 3 | require "dokno/version" 4 | 5 | Gem::Specification.new do |spec| 6 | spec.name = "dokno" 7 | spec.version = Dokno::VERSION 8 | spec.authors = ["Courtney Payne"] 9 | spec.email = ["cpayne624@gmail.com"] 10 | spec.homepage = "https://github.com/cpayne624/dokno" 11 | spec.summary = "Dokno (dough-no) is a lightweight mountable Rails Engine for storing and managing your app's domain knowledge." 12 | spec.description = "Dokno allows you to easily mount a self-contained knowledgebase / wiki / help system to your Rails app." 13 | spec.license = "MIT" 14 | spec.metadata = { 15 | "bug_tracker_uri" => "https://github.com/cpayne624/dokno/issues", 16 | "changelog_uri" => "https://github.com/cpayne624/dokno/blob/master/CHANGELOG.md" 17 | } 18 | 19 | spec.files = Dir["{app,config,db,lib}/**/*", "MIT-LICENSE", "Rakefile", "README.md"] 20 | 21 | spec.add_dependency "diffy", "~> 3.4" # Change log diffing 22 | spec.add_dependency "redcarpet", "~> 3.6" # Markdown to HTML processor 23 | 24 | spec.add_development_dependency "rails", "~> 8" 25 | 26 | spec.add_development_dependency "capybara" 27 | spec.add_development_dependency "database_cleaner-active_record" 28 | spec.add_development_dependency "faker" 29 | spec.add_development_dependency "pg" 30 | spec.add_development_dependency "pry-byebug" 31 | spec.add_development_dependency "puma" 32 | spec.add_development_dependency "rspec-rails" 33 | spec.add_development_dependency "selenium-webdriver" 34 | spec.add_development_dependency "simplecov" 35 | spec.add_development_dependency "sprockets-rails" 36 | end 37 | -------------------------------------------------------------------------------- /lib/dokno.rb: -------------------------------------------------------------------------------- 1 | require "dokno/engine" 2 | 3 | module Dokno 4 | end 5 | -------------------------------------------------------------------------------- /lib/dokno/config/config.rb: -------------------------------------------------------------------------------- 1 | require 'active_support/time' 2 | 3 | module Dokno 4 | module Error 5 | class Config < StandardError; end 6 | end 7 | 8 | def self.configure 9 | yield config 10 | config.validate 11 | end 12 | 13 | def self.config 14 | @config ||= Config.new 15 | end 16 | 17 | class Config 18 | # Dokno configuration options 19 | # 20 | # app_name (String) 21 | # Host app name for display within the mounted dashboard 22 | # tag_whitelist (Enumerable) 23 | # Determines which HTML tags are allowed in Article markdown 24 | # attr_whitelist (Enumerable) 25 | # Determines which HTML attributes are allowed in Article markdown 26 | # app_user_object (String) 27 | # Host app's user object 28 | # app_user_auth_method (Symbol) 29 | # Host app's user object method to be used for edit authorization. 30 | # Should return boolean 31 | # app_user_name_method (Symbol) 32 | # Host app's user object method that returns the authenticated user's name or other 33 | # identifier that will be included in change log events. 34 | # Should return a string 35 | # article_review_period (ActiveSupport::Duration) 36 | # The amount of time before articles should be reviewed for accuracy/relevance 37 | # article_review_prompt_days (Integer) 38 | # The number of days prior to an article being up for review that users should be prompted 39 | 40 | attr_accessor :app_name 41 | attr_accessor :tag_whitelist 42 | attr_accessor :attr_whitelist 43 | attr_accessor :app_user_object 44 | attr_accessor :app_user_auth_method 45 | attr_accessor :app_user_name_method 46 | attr_accessor :article_review_period 47 | attr_accessor :article_review_prompt_days 48 | 49 | # Defaults 50 | TAG_WHITELIST = %w[code img h1 h2 h3 h4 h5 h6 a em u i b strong ol ul li table thead tbody tfoot tr th td blockquote hr br p] 51 | ATTR_WHITELIST = %w[src alt title href target] 52 | APP_USER_OBJECT = 'current_user' 53 | APP_USER_AUTH_METHOD = :admin? 54 | APP_USER_NAME_METHOD = :name 55 | ARTICLE_REVIEW_PERIOD = 1.year 56 | ARTICLE_REVIEW_PROMPT_DAYS = 30 57 | 58 | def initialize 59 | self.app_name = Rails.application.class.module_parent.name.underscore.humanize.upcase 60 | self.tag_whitelist = TAG_WHITELIST 61 | self.attr_whitelist = ATTR_WHITELIST 62 | self.app_user_object = APP_USER_OBJECT 63 | self.app_user_auth_method = APP_USER_AUTH_METHOD 64 | self.app_user_name_method = APP_USER_NAME_METHOD 65 | self.article_review_period = ARTICLE_REVIEW_PERIOD 66 | self.article_review_prompt_days = ARTICLE_REVIEW_PROMPT_DAYS 67 | end 68 | 69 | def validate 70 | validate_config_option(option: 'tag_whitelist', expected_class: Enumerable, example: '%w[a p strong]') 71 | validate_config_option(option: 'attr_whitelist', expected_class: Enumerable, example: '%w[class href]') 72 | validate_config_option(option: 'app_user_object', expected_class: String, example: 'current_user') 73 | validate_config_option(option: 'app_user_auth_method', expected_class: Symbol, example: ':admin?') 74 | validate_config_option(option: 'app_user_name_method', expected_class: Symbol, example: ':name') 75 | validate_config_option(option: 'article_review_period', expected_class: ActiveSupport::Duration, example: '1.year') 76 | validate_config_option(option: 'article_review_prompt_days', expected_class: Integer, example: '30') 77 | end 78 | 79 | def validate_config_option(option:, expected_class:, example:) 80 | return unless !send(option.to_sym).is_a? expected_class 81 | raise Error::Config, "#{config_error_prefix} #{option} must be #{expected_class}, e.g. #{example}" 82 | end 83 | 84 | def config_error_prefix 85 | "Dokno configuration error (check config/initializers/dokno.rb):" 86 | end 87 | end 88 | end 89 | -------------------------------------------------------------------------------- /lib/dokno/engine.rb: -------------------------------------------------------------------------------- 1 | require_relative 'config/config' 2 | 3 | module Dokno 4 | class Engine < ::Rails::Engine 5 | isolate_namespace Dokno 6 | 7 | config.generators do |g| 8 | g.test_framework :rspec 9 | end 10 | 11 | initializer :append_migrations do |app| 12 | config.paths["db/migrate"].expanded.each do |expanded_path| 13 | app.config.paths["db/migrate"] << expanded_path 14 | end 15 | end 16 | 17 | initializer 'Dokno precompile', group: :all do |app| 18 | app.config.assets.precompile << "dokno_manifest.js" 19 | end 20 | 21 | initializer 'local_helper.action_controller' do 22 | ActiveSupport.on_load :action_controller_base do 23 | helper Dokno::ApplicationHelper 24 | end 25 | end 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /lib/dokno/version.rb: -------------------------------------------------------------------------------- 1 | module Dokno 2 | VERSION = '1.4.14' 3 | end 4 | -------------------------------------------------------------------------------- /lib/generators/dokno/install/install_generator.rb: -------------------------------------------------------------------------------- 1 | module Dokno 2 | module Generators 3 | class InstallGenerator < Rails::Generators::Base 4 | source_root File.expand_path('../../templates', __FILE__) 5 | 6 | desc 'Copies the Dokno configuration templates to the host app' 7 | def copy_initializer 8 | template 'config/initializers/dokno.rb', 'config/initializers/dokno.rb' 9 | template 'config/dokno_template.md', 'config/dokno_template.md' 10 | end 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /lib/generators/dokno/templates/config/dokno_template.md: -------------------------------------------------------------------------------- 1 | This is your article template. It's helpful for adding consistency to your app's knowledgebase articles. 2 | 3 | Articles support basic HTML and **markdown**. 4 | 5 | You can modify this template in `/config/dokno_template.md` 6 | -------------------------------------------------------------------------------- /lib/generators/dokno/templates/config/initializers/dokno.rb: -------------------------------------------------------------------------------- 1 | Dokno.configure do |config| 2 | # To control the permitted HTML tags and attributes within articles, 3 | # uncomment and change the defaults. 4 | # (Enumerable) tag_whitelist 5 | # (Enumerable) attr_whitelist 6 | # config.tag_whitelist = %w[code img h1 h2 h3 h4 h5 h6 a em u i b strong ol ul li table thead tbody tfoot tr th td blockquote hr br p] 7 | # config.attr_whitelist = %w[src alt title href target] 8 | 9 | # To restrict Dokno data modification and include indentifying information 10 | # in change log entries, provide the appropriate user values for your app below. 11 | # (String) app_user_object 12 | # (Symbol) app_user_auth_method 13 | # (Symbol) app_user_name_method 14 | # config.app_user_object = 'current_user' 15 | # config.app_user_auth_method = :admin? 16 | # config.app_user_name_method = :name 17 | 18 | # To control the amount of time before a created/updated article is flagged 19 | # for accuracy/relevance review, uncomment and change the default. 20 | # (ActiveSupport::Duration) article_review_period 21 | # config.article_review_period = 1.year 22 | 23 | # To control the number of days prior to an article being up for review 24 | # that users should be prompted to re-review, uncomment and change the default. 25 | # (Integer) article_review_prompt_days 26 | # config.article_review_prompt_days = 30 27 | end 28 | -------------------------------------------------------------------------------- /lib/tasks/dokno_tasks.rake: -------------------------------------------------------------------------------- 1 | # desc "Explaining what the task does" 2 | # task :dokno do 3 | # # Task goes here 4 | # end 5 | -------------------------------------------------------------------------------- /spec/dummy/.ruby-version: -------------------------------------------------------------------------------- 1 | ruby-3.2.2 2 | -------------------------------------------------------------------------------- /spec/dummy/Rakefile: -------------------------------------------------------------------------------- 1 | # Add your own tasks in files placed in lib/tasks ending in .rake, 2 | # for example lib/tasks/capistrano.rake, and they will automatically be available to Rake. 3 | 4 | require_relative 'config/application' 5 | 6 | Rails.application.load_tasks 7 | -------------------------------------------------------------------------------- /spec/dummy/app/assets/config/manifest.js: -------------------------------------------------------------------------------- 1 | //= link_tree ../images 2 | //= link_directory ../stylesheets .css 3 | -------------------------------------------------------------------------------- /spec/dummy/app/assets/images/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cpayne624/dokno/86c8b2aeb960b6bd4b63fd63117b541d47f49fe9/spec/dummy/app/assets/images/.keep -------------------------------------------------------------------------------- /spec/dummy/app/assets/stylesheets/application.css: -------------------------------------------------------------------------------- 1 | /* 2 | * This is a manifest file that'll be compiled into application.css, which will include all the files 3 | * listed below. 4 | * 5 | * Any CSS and SCSS file within this directory, lib/assets/stylesheets, vendor/assets/stylesheets, 6 | * or any plugin's vendor/assets/stylesheets directory can be referenced here using a relative path. 7 | * 8 | * You're free to add application-wide styles to this file and they'll appear at the bottom of the 9 | * compiled file so the styles you add here take precedence over styles defined in any other CSS/SCSS 10 | * files in this directory. Styles in this file should be added after the last require_* statement. 11 | * It is generally better to create a new file per style scope. 12 | * 13 | *= require_tree . 14 | *= require_self 15 | */ 16 | 17 | body { 18 | margin: 1.5rem; 19 | } 20 | 21 | div#dummy-page p, 22 | div#dummy-page li { 23 | line-height: 1.5rem; 24 | } 25 | -------------------------------------------------------------------------------- /spec/dummy/app/controllers/application_controller.rb: -------------------------------------------------------------------------------- 1 | class ApplicationController < ActionController::Base 2 | include ApplicationHelper 3 | 4 | helper_method :current_user 5 | 6 | private 7 | 8 | # See spec/dummy/app/lib/user.rb for mocked User model for authorization testing 9 | def current_user 10 | @current_user ||= User.new 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /spec/dummy/app/controllers/concerns/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cpayne624/dokno/86c8b2aeb960b6bd4b63fd63117b541d47f49fe9/spec/dummy/app/controllers/concerns/.keep -------------------------------------------------------------------------------- /spec/dummy/app/controllers/dummy_controller.rb: -------------------------------------------------------------------------------- 1 | class DummyController < ApplicationController 2 | def dummy; end 3 | end 4 | -------------------------------------------------------------------------------- /spec/dummy/app/helpers/application_helper.rb: -------------------------------------------------------------------------------- 1 | module ApplicationHelper 2 | end 3 | -------------------------------------------------------------------------------- /spec/dummy/app/lib/user.rb: -------------------------------------------------------------------------------- 1 | # Mocked User model for authorization testing 2 | class User 3 | def name 4 | 'Dummy User' 5 | end 6 | 7 | def admin? 8 | true 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /spec/dummy/app/models/application_record.rb: -------------------------------------------------------------------------------- 1 | class ApplicationRecord < ActiveRecord::Base 2 | self.abstract_class = true 3 | end 4 | -------------------------------------------------------------------------------- /spec/dummy/app/models/concerns/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cpayne624/dokno/86c8b2aeb960b6bd4b63fd63117b541d47f49fe9/spec/dummy/app/models/concerns/.keep -------------------------------------------------------------------------------- /spec/dummy/app/views/dummy/dummy.html.erb: -------------------------------------------------------------------------------- 1 |

Dokno Example Rails App

2 |

This is a minimal example Rails app with the Dokno engine mounted.

3 |

Dokno can be mounted to your desired path within your app. The demo path has been configured to <%= dokno_path %>.

4 | 5 |
6 | Open the Demo 7 |
8 | 9 |

Source

10 |

Reference the repo for this site for example configuration.

11 | 12 | <% unless Dokno::Article.exists? %> 13 |

Demo

14 |

15 | The above link will take you to the mounted Dokno site. 16 |

17 | <% else %> 18 |

A page in your app

19 |

20 | This might be something in your app that needs clarification or further
21 | background, with an <%= dokno_article_link('in-context link like this one', slug: Dokno::Article.take&.slug) %> 22 | that explains it all.
23 | Clicking that link opens a flyout within your app (to the right) containing
24 | the article referenced by its unique slug or token. 25 |

26 | 27 |

Flyout Article View Helper

28 |

29 | You can add in-context article links like these within your app via the dokno_article_link view helper: 30 |

31 |
32 | <%= dokno_article_link({link-text}, slug: {unique-article-slug}) %> 33 |
34 | 35 |

Created Articles

36 |
    37 | <% Dokno::Article.select(:slug, :title).alpha_order.each do |article| %> 38 |
  • <%= dokno_article_link(article.title, slug: article.slug) %> (<%= article.slug %>)
  • 39 | <% end %> 40 |
41 | <% end %> 42 | -------------------------------------------------------------------------------- /spec/dummy/app/views/layouts/application.html.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Dummy 5 | 6 | <%= csrf_meta_tags %> 7 | <%= csp_meta_tag %> 8 | 9 | <%= stylesheet_link_tag 'application', media: 'all' %> 10 | 11 | 12 | 13 | <%= yield %> 14 | 15 | <%= render 'dokno/article_panel' %> 16 | 17 | 18 | -------------------------------------------------------------------------------- /spec/dummy/bin/rails: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | APP_PATH = File.expand_path('../config/application', __dir__) 3 | require_relative '../config/boot' 4 | require 'rails/commands' 5 | -------------------------------------------------------------------------------- /spec/dummy/bin/rake: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | require_relative '../config/boot' 3 | require 'rake' 4 | Rake.application.run 5 | -------------------------------------------------------------------------------- /spec/dummy/bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | require 'fileutils' 3 | 4 | # path to your application root. 5 | APP_ROOT = File.expand_path('..', __dir__) 6 | 7 | def system!(*args) 8 | system(*args) || abort("\n== Command #{args} failed ==") 9 | end 10 | 11 | FileUtils.chdir APP_ROOT do 12 | # This script is a way to setup or update your development environment automatically. 13 | # This script is idempotent, so that you can run it at anytime and get an expectable outcome. 14 | # Add necessary setup steps to this file. 15 | 16 | puts '== Installing dependencies ==' 17 | system! 'gem install bundler --conservative' 18 | system('bundle check') || system!('bundle install') 19 | 20 | # puts "\n== Copying sample files ==" 21 | # unless File.exist?('config/database.yml') 22 | # FileUtils.cp 'config/database.yml.sample', 'config/database.yml' 23 | # end 24 | 25 | puts "\n== Preparing database ==" 26 | system! 'bin/rails db:prepare' 27 | 28 | puts "\n== Removing old logs and tempfiles ==" 29 | system! 'bin/rails log:clear tmp:clear' 30 | 31 | puts "\n== Restarting application server ==" 32 | system! 'bin/rails restart' 33 | end 34 | -------------------------------------------------------------------------------- /spec/dummy/config.ru: -------------------------------------------------------------------------------- 1 | # This file is used by Rack-based servers to start the application. 2 | 3 | require_relative 'config/environment' 4 | 5 | run Rails.application 6 | -------------------------------------------------------------------------------- /spec/dummy/config/application.rb: -------------------------------------------------------------------------------- 1 | require_relative 'boot' 2 | 3 | require "rails" 4 | # Pick the frameworks you want: 5 | require "active_model/railtie" 6 | require "active_job/railtie" 7 | require "active_record/railtie" 8 | require "active_storage/engine" 9 | require "action_controller/railtie" 10 | require "action_mailer/railtie" 11 | require "action_view/railtie" 12 | require "action_cable/engine" 13 | require "sprockets/railtie" 14 | 15 | Bundler.require(*Rails.groups) 16 | require "dokno" 17 | 18 | module Dummy 19 | class Application < Rails::Application 20 | # https://guides.rubyonrails.org/configuring.html 21 | # Initialize configuration defaults for originally generated Rails version. 22 | # config.load_defaults 6.0 23 | 24 | # Settings in config/environments/* take precedence over those specified here. 25 | # Application configuration can go into files in config/initializers 26 | # -- all .rb files in that directory are automatically loaded after loading 27 | # the framework and any gems in your application. 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /spec/dummy/config/boot.rb: -------------------------------------------------------------------------------- 1 | # Set up gems listed in the Gemfile. 2 | ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../../../Gemfile', __dir__) 3 | 4 | require 'bundler/setup' if File.exist?(ENV['BUNDLE_GEMFILE']) 5 | $LOAD_PATH.unshift File.expand_path('../../../lib', __dir__) 6 | -------------------------------------------------------------------------------- /spec/dummy/config/database.yml: -------------------------------------------------------------------------------- 1 | # PostgreSQL. Versions 9.3 and up are supported. 2 | # 3 | # Install the pg driver: 4 | # gem install pg 5 | # On macOS with Homebrew: 6 | # gem install pg -- --with-pg-config=/usr/local/bin/pg_config 7 | # On macOS with MacPorts: 8 | # gem install pg -- --with-pg-config=/opt/local/lib/postgresql84/bin/pg_config 9 | # On Windows: 10 | # gem install pg 11 | # Choose the win32 build. 12 | # Install PostgreSQL and put its /bin directory on your path. 13 | # 14 | # Configure Using Gemfile 15 | # gem 'pg' 16 | # 17 | default: &default 18 | adapter: postgresql 19 | encoding: unicode 20 | host: localhost 21 | username: postgres 22 | password: postgres 23 | # For details on connection pooling, see Rails configuration guide 24 | # https://guides.rubyonrails.org/configuring.html#database-pooling 25 | pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %> 26 | 27 | development: 28 | <<: *default 29 | database: dokno_host_development 30 | 31 | # The specified database role being used to connect to postgres. 32 | # To create additional roles in postgres see `$ createuser --help`. 33 | # When left blank, postgres will use the default role. This is 34 | # the same name as the operating system user that initialized the database. 35 | #username: dokno_host 36 | 37 | # The password associated with the postgres role (username). 38 | #password: 39 | 40 | # Connect on a TCP socket. Omitted by default since the client uses a 41 | # domain socket that doesn't need configuration. Windows does not have 42 | # domain sockets, so uncomment these lines. 43 | #host: localhost 44 | 45 | # The TCP port the server listens on. Defaults to 5432. 46 | # If your server runs on a different port number, change accordingly. 47 | #port: 5432 48 | 49 | # Schema search path. The server defaults to $user,public 50 | #schema_search_path: myapp,sharedapp,public 51 | 52 | # Minimum log levels, in increasing order: 53 | # debug5, debug4, debug3, debug2, debug1, 54 | # log, notice, warning, error, fatal, and panic 55 | # Defaults to warning. 56 | #min_messages: notice 57 | 58 | # Warning: The database defined as "test" will be erased and 59 | # re-generated from your development database when you run "rake". 60 | # Do not set this db to the same as development or production. 61 | test: 62 | <<: *default 63 | database: dokno_host_test 64 | 65 | # As with config/credentials.yml, you never want to store sensitive information, 66 | # like your database password, in your source code. If your source code is 67 | # ever seen by anyone, they now have access to your database. 68 | # 69 | # Instead, provide the password as a unix environment variable when you boot 70 | # the app. Read https://guides.rubyonrails.org/configuring.html#configuring-a-database 71 | # for a full rundown on how to provide these environment variables in a 72 | # production deployment. 73 | # 74 | # On Heroku and other platform providers, you may have a full connection URL 75 | # available as an environment variable. For example: 76 | # 77 | # DATABASE_URL="postgres://myuser:mypass@localhost/somedatabase" 78 | # 79 | # You can use this database configuration with: 80 | # 81 | # production: 82 | # url: <%= ENV['DATABASE_URL'] %> 83 | # 84 | production: 85 | <<: *default 86 | database: dokno_host_production 87 | username: dokno_host 88 | password: <%= ENV['DOKNO_HOST_DATABASE_PASSWORD'] %> 89 | -------------------------------------------------------------------------------- /spec/dummy/config/dokno_template.md: -------------------------------------------------------------------------------- 1 | This is your article template. It's helpful for adding consistency to your app's knowledgebase articles. 2 | 3 | Articles support basic HTML and **markdown**. 4 | 5 | You can modify this template in `/config/dokno_template.md` 6 | -------------------------------------------------------------------------------- /spec/dummy/config/environment.rb: -------------------------------------------------------------------------------- 1 | # Load the Rails application. 2 | require_relative 'application' 3 | 4 | # Initialize the Rails application. 5 | Rails.application.initialize! 6 | -------------------------------------------------------------------------------- /spec/dummy/config/environments/development.rb: -------------------------------------------------------------------------------- 1 | Rails.application.configure do 2 | # Settings specified here will take precedence over those in config/application.rb. 3 | 4 | # In the development environment your application's code is reloaded on 5 | # every request. This slows down response time but is perfect for development 6 | # since you don't have to restart the web server when you make code changes. 7 | config.cache_classes = false 8 | 9 | # Do not eager load code on boot. 10 | config.eager_load = false 11 | 12 | # Show full error reports. 13 | config.consider_all_requests_local = true 14 | 15 | # Enable/disable caching. By default caching is disabled. 16 | # Run rails dev:cache to toggle caching. 17 | if Rails.root.join('tmp', 'caching-dev.txt').exist? 18 | config.action_controller.perform_caching = true 19 | config.action_controller.enable_fragment_cache_logging = true 20 | 21 | config.cache_store = :memory_store 22 | config.public_file_server.headers = { 23 | 'Cache-Control' => "public, max-age=#{2.days.to_i}" 24 | } 25 | else 26 | config.action_controller.perform_caching = false 27 | 28 | config.cache_store = :null_store 29 | end 30 | 31 | # Store uploaded files on the local file system (see config/storage.yml for options). 32 | config.active_storage.service = :local 33 | 34 | # Don't care if the mailer can't send. 35 | config.action_mailer.raise_delivery_errors = false 36 | 37 | config.action_mailer.perform_caching = false 38 | 39 | # Print deprecation notices to the Rails logger. 40 | config.active_support.deprecation = :log 41 | 42 | # Raise an error on page load if there are pending migrations. 43 | config.active_record.migration_error = :page_load 44 | 45 | # Highlight code that triggered database queries in logs. 46 | config.active_record.verbose_query_logs = true 47 | 48 | # Debug mode disables concatenation and preprocessing of assets. 49 | # This option may cause significant delays in view rendering with a large 50 | # number of complex assets. 51 | config.assets.debug = true 52 | 53 | # Suppress logger output for asset requests. 54 | config.assets.quiet = true 55 | 56 | # config.assets.check_precompiled_asset = false 57 | 58 | # Raises error for missing translations. 59 | # config.action_view.raise_on_missing_translations = true 60 | 61 | # Use an evented file watcher to asynchronously detect changes in source code, 62 | # routes, locales, etc. This feature depends on the listen gem. 63 | # config.file_watcher = ActiveSupport::EventedFileUpdateChecker 64 | 65 | config.hosts = nil 66 | end 67 | -------------------------------------------------------------------------------- /spec/dummy/config/environments/test.rb: -------------------------------------------------------------------------------- 1 | # The test environment is used exclusively to run your application's 2 | # test suite. You never need to work with it otherwise. Remember that 3 | # your test database is "scratch space" for the test suite and is wiped 4 | # and recreated between test runs. Don't rely on the data there! 5 | 6 | Rails.application.configure do 7 | # Settings specified here will take precedence over those in config/application.rb. 8 | 9 | config.cache_classes = false 10 | config.action_view.cache_template_loading = true 11 | 12 | # Do not eager load code on boot. This avoids loading your whole application 13 | # just for the purpose of running a single test. If you are using a tool that 14 | # preloads Rails for running tests, you may have to set it to true. 15 | config.eager_load = false 16 | 17 | # Configure public file server for tests with Cache-Control for performance. 18 | config.public_file_server.enabled = true 19 | config.public_file_server.headers = { 20 | 'Cache-Control' => "public, max-age=#{1.hour.to_i}" 21 | } 22 | 23 | # Show full error reports and disable caching. 24 | config.consider_all_requests_local = true 25 | config.action_controller.perform_caching = false 26 | config.cache_store = :null_store 27 | 28 | # Raise exceptions instead of rendering exception templates. 29 | config.action_dispatch.show_exceptions = false 30 | 31 | # Disable request forgery protection in test environment. 32 | config.action_controller.allow_forgery_protection = false 33 | 34 | # Store uploaded files on the local file system in a temporary directory. 35 | config.active_storage.service = :test 36 | 37 | config.action_mailer.perform_caching = false 38 | 39 | # Tell Action Mailer not to deliver emails to the real world. 40 | # The :test delivery method accumulates sent emails in the 41 | # ActionMailer::Base.deliveries array. 42 | config.action_mailer.delivery_method = :test 43 | 44 | # Print deprecation notices to the stderr. 45 | config.active_support.deprecation = :stderr 46 | 47 | # Raises error for missing translations. 48 | # config.action_view.raise_on_missing_translations = true 49 | 50 | # Debug mode disables concatenation and preprocessing of assets. 51 | # This option may cause significant delays in view rendering with a large 52 | # number of complex assets. 53 | config.assets.debug = true 54 | 55 | # Suppress logger output for asset requests. 56 | config.assets.quiet = true 57 | 58 | config.assets.check_precompiled_asset = false 59 | 60 | # Raises error for missing translations. 61 | # config.action_view.raise_on_missing_translations = true 62 | 63 | # Use an evented file watcher to asynchronously detect changes in source code, 64 | # routes, locales, etc. This feature depends on the listen gem. 65 | # config.file_watcher = ActiveSupport::EventedFileUpdateChecker 66 | 67 | config.hosts = nil 68 | end 69 | -------------------------------------------------------------------------------- /spec/dummy/config/initializers/application_controller_renderer.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # ActiveSupport::Reloader.to_prepare do 4 | # ApplicationController.renderer.defaults.merge!( 5 | # http_host: 'example.org', 6 | # https: false 7 | # ) 8 | # end 9 | -------------------------------------------------------------------------------- /spec/dummy/config/initializers/assets.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Version of your assets, change this if you want to expire all your assets. 4 | Rails.application.config.assets.version = '1.0' 5 | 6 | # Add additional assets to the asset load path. 7 | # Rails.application.config.assets.paths << Emoji.images_path 8 | 9 | # Precompile additional assets. 10 | # application.js, application.css, and all non-JS/CSS in the app/assets 11 | # folder are already added. 12 | # Rails.application.config.assets.precompile += %w( admin.js admin.css ) 13 | -------------------------------------------------------------------------------- /spec/dummy/config/initializers/backtrace_silencers.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # You can add backtrace silencers for libraries that you're using but don't wish to see in your backtraces. 4 | # Rails.backtrace_cleaner.add_silencer { |line| line =~ /my_noisy_library/ } 5 | 6 | # You can also remove all the silencers if you're trying to debug a problem that might stem from framework code. 7 | # Rails.backtrace_cleaner.remove_silencers! 8 | -------------------------------------------------------------------------------- /spec/dummy/config/initializers/content_security_policy.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Define an application-wide content security policy 4 | # For further information see the following documentation 5 | # https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy 6 | 7 | # Rails.application.config.content_security_policy do |policy| 8 | # policy.default_src :self, :https 9 | # policy.font_src :self, :https, :data 10 | # policy.img_src :self, :https, :data 11 | # policy.object_src :none 12 | # policy.script_src :self, :https 13 | # policy.style_src :self, :https 14 | 15 | # # Specify URI for violation reports 16 | # # policy.report_uri "/csp-violation-report-endpoint" 17 | # end 18 | 19 | # If you are using UJS then enable automatic nonce generation 20 | # Rails.application.config.content_security_policy_nonce_generator = -> request { SecureRandom.base64(16) } 21 | 22 | # Set the nonce only to specific directives 23 | # Rails.application.config.content_security_policy_nonce_directives = %w(script-src) 24 | 25 | # Report CSP violations to a specified URI 26 | # For further information see the following documentation: 27 | # https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy-Report-Only 28 | # Rails.application.config.content_security_policy_report_only = true 29 | -------------------------------------------------------------------------------- /spec/dummy/config/initializers/cookies_serializer.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Specify a serializer for the signed and encrypted cookie jars. 4 | # Valid options are :json, :marshal, and :hybrid. 5 | Rails.application.config.action_dispatch.cookies_serializer = :json 6 | -------------------------------------------------------------------------------- /spec/dummy/config/initializers/dokno.rb: -------------------------------------------------------------------------------- 1 | Dokno.configure do |config| 2 | # To control the permitted HTML tags and attributes within articles, 3 | # uncomment and change the defaults. 4 | # (Enumerable) tag_whitelist 5 | # (Enumerable) attr_whitelist 6 | # config.tag_whitelist = %w[code img h1 h2 h3 h4 h5 h6 a em u i b strong ol ul li table thead tbody tfoot tr th td blockquote hr br p] 7 | # config.attr_whitelist = %w[src alt title href target] 8 | 9 | # To restrict Dokno data modification and include indentifying information 10 | # in change log entries, provide the appropriate user values for your app below. 11 | # (String) app_user_object 12 | # (Symbol) app_user_auth_method 13 | # (Symbol) app_user_name_method 14 | # config.app_user_object = 'current_user' 15 | # config.app_user_auth_method = :admin? 16 | # config.app_user_name_method = :name 17 | 18 | # To control the amount of time before a created/updated article is flagged 19 | # for accuracy/relevance review, uncomment and change the default. 20 | # (ActiveSupport::Duration) article_review_period 21 | # config.article_review_period = 1.year 22 | 23 | # To control the number of days prior to an article being up for review 24 | # that users should be prompted to re-review, uncomment and change the default. 25 | # (Integer) article_review_prompt_days 26 | # config.article_review_prompt_days = 30 27 | end 28 | -------------------------------------------------------------------------------- /spec/dummy/config/initializers/filter_parameter_logging.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Configure sensitive parameters which will be filtered from the log file. 4 | Rails.application.config.filter_parameters += [:password] 5 | -------------------------------------------------------------------------------- /spec/dummy/config/initializers/inflections.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Add new inflection rules using the following format. Inflections 4 | # are locale specific, and you may define rules for as many different 5 | # locales as you wish. All of these examples are active by default: 6 | # ActiveSupport::Inflector.inflections(:en) do |inflect| 7 | # inflect.plural /^(ox)$/i, '\1en' 8 | # inflect.singular /^(ox)en/i, '\1' 9 | # inflect.irregular 'person', 'people' 10 | # inflect.uncountable %w( fish sheep ) 11 | # end 12 | 13 | # These inflection rules are supported but not enabled by default: 14 | # ActiveSupport::Inflector.inflections(:en) do |inflect| 15 | # inflect.acronym 'RESTful' 16 | # end 17 | -------------------------------------------------------------------------------- /spec/dummy/config/initializers/mime_types.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Add new mime types for use in respond_to blocks: 4 | # Mime::Type.register "text/richtext", :rtf 5 | -------------------------------------------------------------------------------- /spec/dummy/config/initializers/wrap_parameters.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # This file contains settings for ActionController::ParamsWrapper which 4 | # is enabled by default. 5 | 6 | # Enable parameter wrapping for JSON. You can disable this by setting :format to an empty array. 7 | ActiveSupport.on_load(:action_controller) do 8 | wrap_parameters format: [:json] 9 | end 10 | 11 | # To enable root element in JSON for ActiveRecord objects. 12 | # ActiveSupport.on_load(:active_record) do 13 | # self.include_root_in_json = true 14 | # end 15 | -------------------------------------------------------------------------------- /spec/dummy/config/locales/en.yml: -------------------------------------------------------------------------------- 1 | # Files in the config/locales directory are used for internationalization 2 | # and are automatically loaded by Rails. If you want to use locales other 3 | # than English, add the necessary files in this directory. 4 | # 5 | # To use the locales, use `I18n.t`: 6 | # 7 | # I18n.t 'hello' 8 | # 9 | # In views, this is aliased to just `t`: 10 | # 11 | # <%= t('hello') %> 12 | # 13 | # To use a different locale, set it with `I18n.locale`: 14 | # 15 | # I18n.locale = :es 16 | # 17 | # This would use the information in config/locales/es.yml. 18 | # 19 | # The following keys must be escaped otherwise they will not be retrieved by 20 | # the default I18n backend: 21 | # 22 | # true, false, on, off, yes, no 23 | # 24 | # Instead, surround them with single quotes. 25 | # 26 | # en: 27 | # 'true': 'foo' 28 | # 29 | # To learn more, please read the Rails Internationalization guide 30 | # available at https://guides.rubyonrails.org/i18n.html. 31 | 32 | en: 33 | hello: "Hello world" 34 | -------------------------------------------------------------------------------- /spec/dummy/config/puma.rb: -------------------------------------------------------------------------------- 1 | # Puma can serve each request in a thread from an internal thread pool. 2 | # The `threads` method setting takes two numbers: a minimum and maximum. 3 | # Any libraries that use thread pools should be configured to match 4 | # the maximum value specified for Puma. Default is set to 5 threads for minimum 5 | # and maximum; this matches the default thread size of Active Record. 6 | # 7 | max_threads_count = ENV.fetch("RAILS_MAX_THREADS") { 5 } 8 | min_threads_count = ENV.fetch("RAILS_MIN_THREADS") { max_threads_count } 9 | threads min_threads_count, max_threads_count 10 | 11 | # Specifies the `port` that Puma will listen on to receive requests; default is 3000. 12 | # 13 | port ENV.fetch("PORT") { 3000 } 14 | 15 | # Specifies the `environment` that Puma will run in. 16 | # 17 | environment ENV.fetch("RAILS_ENV") { "development" } 18 | 19 | # Specifies the `pidfile` that Puma will use. 20 | pidfile ENV.fetch("PIDFILE") { "tmp/pids/server.pid" } 21 | 22 | # Specifies the number of `workers` to boot in clustered mode. 23 | # Workers are forked web server processes. If using threads and workers together 24 | # the concurrency of the application would be max `threads` * `workers`. 25 | # Workers do not work on JRuby or Windows (both of which do not support 26 | # processes). 27 | # 28 | # workers ENV.fetch("WEB_CONCURRENCY") { 2 } 29 | 30 | # Use the `preload_app!` method when specifying a `workers` number. 31 | # This directive tells Puma to first boot the application and load code 32 | # before forking the application. This takes advantage of Copy On Write 33 | # process behavior so workers use less memory. 34 | # 35 | # preload_app! 36 | 37 | # Allow puma to be restarted by `rails restart` command. 38 | plugin :tmp_restart 39 | -------------------------------------------------------------------------------- /spec/dummy/config/routes.rb: -------------------------------------------------------------------------------- 1 | Rails.application.routes.draw do 2 | mount Dokno::Engine => "/help" 3 | 4 | root 'dummy#dummy' 5 | end 6 | -------------------------------------------------------------------------------- /spec/dummy/config/spring.rb: -------------------------------------------------------------------------------- 1 | Spring.watch( 2 | ".ruby-version", 3 | ".rbenv-vars", 4 | "tmp/restart.txt", 5 | "tmp/caching-dev.txt" 6 | ) 7 | -------------------------------------------------------------------------------- /spec/dummy/db/schema.rb: -------------------------------------------------------------------------------- 1 | # This file is auto-generated from the current state of the database. Instead 2 | # of editing this file, please use the migrations feature of Active Record to 3 | # incrementally modify your database, and then regenerate this schema definition. 4 | # 5 | # This file is the source Rails uses to define your schema when running `rails 6 | # db:schema:load`. When creating a new database, `rails db:schema:load` tends to 7 | # be faster and is potentially less error prone than running all of your 8 | # migrations from scratch. Old migrations may fail to apply correctly if those 9 | # migrations use external dependencies or application code. 10 | # 11 | # It's strongly recommended that you check this file into your version control system. 12 | 13 | ActiveRecord::Schema.define(version: 2020_12_13_165700) do 14 | 15 | # These are extensions that must be enabled in order to support this database 16 | enable_extension "plpgsql" 17 | 18 | create_table "dokno_article_slugs", force: :cascade do |t| 19 | t.string "slug", null: false 20 | t.bigint "article_id" 21 | t.datetime "created_at", precision: 6, null: false 22 | t.datetime "updated_at", precision: 6, null: false 23 | t.index ["article_id"], name: "index_dokno_article_slugs_on_article_id" 24 | t.index ["slug"], name: "index_dokno_article_slugs_on_slug", unique: true 25 | end 26 | 27 | create_table "dokno_articles", force: :cascade do |t| 28 | t.string "slug" 29 | t.string "title" 30 | t.text "markdown" 31 | t.text "summary" 32 | t.boolean "active", default: true 33 | t.bigint "views", default: 0 34 | t.datetime "last_viewed_at" 35 | t.datetime "created_at", precision: 6, null: false 36 | t.datetime "updated_at", precision: 6, null: false 37 | t.datetime "review_due_at" 38 | t.boolean "starred", default: false 39 | t.index ["review_due_at"], name: "index_dokno_articles_on_review_due_at" 40 | t.index ["slug"], name: "index_dokno_articles_on_slug", unique: true 41 | end 42 | 43 | create_table "dokno_articles_categories", id: false, force: :cascade do |t| 44 | t.bigint "article_id" 45 | t.bigint "category_id" 46 | t.index ["article_id", "category_id"], name: "index_dokno_articles_categories_on_article_id_and_category_id", unique: true 47 | t.index ["article_id"], name: "index_dokno_articles_categories_on_article_id" 48 | t.index ["category_id"], name: "index_dokno_articles_categories_on_category_id" 49 | end 50 | 51 | create_table "dokno_categories", force: :cascade do |t| 52 | t.string "name" 53 | t.bigint "category_id" 54 | t.string "code", null: false 55 | t.datetime "created_at", precision: 6, null: false 56 | t.datetime "updated_at", precision: 6, null: false 57 | t.index ["category_id"], name: "index_dokno_categories_on_category_id" 58 | t.index ["code"], name: "index_dokno_categories_on_code", unique: true 59 | t.index ["name"], name: "index_dokno_categories_on_name", unique: true 60 | end 61 | 62 | create_table "dokno_logs", force: :cascade do |t| 63 | t.bigint "article_id" 64 | t.string "username" 65 | t.text "meta" 66 | t.text "diff_left" 67 | t.text "diff_right" 68 | t.datetime "created_at", precision: 6, null: false 69 | t.datetime "updated_at", precision: 6, null: false 70 | t.index ["article_id"], name: "index_dokno_logs_on_article_id" 71 | t.index ["username"], name: "index_dokno_logs_on_username" 72 | end 73 | 74 | add_foreign_key "dokno_article_slugs", "dokno_articles", column: "article_id" 75 | add_foreign_key "dokno_articles_categories", "dokno_articles", column: "article_id" 76 | add_foreign_key "dokno_articles_categories", "dokno_categories", column: "category_id" 77 | add_foreign_key "dokno_categories", "dokno_categories", column: "category_id" 78 | add_foreign_key "dokno_logs", "dokno_articles", column: "article_id" 79 | end 80 | -------------------------------------------------------------------------------- /spec/dummy/db/seeds.rb: -------------------------------------------------------------------------------- 1 | DatabaseCleaner.allow_remote_database_url = true 2 | DatabaseCleaner.clean_with(:truncation) 3 | 4 | module Dokno 5 | SPINNER = Enumerator.new do |e| 6 | loop do 7 | e.yield '|' 8 | e.yield '/' 9 | e.yield '-' 10 | e.yield '\\' 11 | end 12 | end 13 | 14 | def self.show_step(step); printf " \n#{step} "; end 15 | def self.show_progress; printf "#{SPINNER.next}\b"; end 16 | def self.show_done; printf " \nAll done\n\n"; end 17 | 18 | def self.faker_markdown 19 | %( 20 | # #{Faker::Company.catch_phrase} 21 | #{Faker::Markdown.emphasis} #{Faker::Markdown.emphasis} 22 | 23 | ## #{Faker::Company.catch_phrase} 24 | #{Faker::Lorem.paragraph(sentence_count: 20, random_sentences_to_add: 50)} 25 | 26 | ### #{Faker::Company.catch_phrase} 27 | #{Faker::Lorem.paragraph(sentence_count: 20, random_sentences_to_add: 50)} 28 | ) 29 | end 30 | 31 | # Categories 32 | 33 | show_step 'Categories' 34 | 10.times do 35 | Category.create(name: Faker::Company.industry) 36 | show_progress 37 | end 38 | 39 | 10.times do 40 | Category.all.sample.children << Category.new(name: Faker::Company.profession.capitalize) 41 | show_progress 42 | end 43 | 44 | 10.times do 45 | Category.where.not(category_id: nil).all.sample.children << Category.new(name: Faker::Company.type) 46 | show_progress 47 | end 48 | 49 | # Articles 50 | 51 | show_step 'Categorized articles' 52 | Category.find_each do |category| 53 | rand(0..15).times do 54 | category.articles << Article.new( 55 | slug: Faker::Lorem.characters(number: 12), 56 | title: Faker::Company.catch_phrase, 57 | summary: Faker::Lorem.paragraph(sentence_count: 5, random_sentences_to_add: 10), 58 | markdown: faker_markdown, 59 | review_due_at: Date.today + (rand(-30..365)).days 60 | ) 61 | show_progress 62 | end 63 | end 64 | 65 | show_step 'Uncategorized articles' 66 | 15.times do 67 | Article.create( 68 | slug: Faker::Lorem.characters(number: 12), 69 | title: Faker::Company.catch_phrase, 70 | summary: Faker::Lorem.paragraph(sentence_count: 5, random_sentences_to_add: 10), 71 | markdown: faker_markdown, 72 | review_due_at: Date.today + (rand(-30..365)).days 73 | ) 74 | show_progress 75 | end 76 | 77 | show_step 'Starred articles' 78 | Article.where(id: Article.all.pluck(:id).shuffle.first(15)).update_all(starred: true) 79 | 80 | show_done 81 | end 82 | -------------------------------------------------------------------------------- /spec/dummy/lib/assets/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cpayne624/dokno/86c8b2aeb960b6bd4b63fd63117b541d47f49fe9/spec/dummy/lib/assets/.keep -------------------------------------------------------------------------------- /spec/dummy/log/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cpayne624/dokno/86c8b2aeb960b6bd4b63fd63117b541d47f49fe9/spec/dummy/log/.keep -------------------------------------------------------------------------------- /spec/dummy/public/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cpayne624/dokno/86c8b2aeb960b6bd4b63fd63117b541d47f49fe9/spec/dummy/public/.keep -------------------------------------------------------------------------------- /spec/dummy/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cpayne624/dokno/86c8b2aeb960b6bd4b63fd63117b541d47f49fe9/spec/dummy/public/favicon.ico -------------------------------------------------------------------------------- /spec/features/index_spec.rb: -------------------------------------------------------------------------------- 1 | describe 'category index page', js: true do 2 | it 'allows article browsing and searching' do 3 | # Empty site, landing page 4 | visit dokno_path 5 | 6 | expect(page).to have_content('Add your first category') 7 | expect(page).not_to have_selector('#category') 8 | expect(page).not_to have_selector('#search_term') 9 | 10 | # Index page with data, no category selected 11 | articles = [ 12 | Dokno::Article.create!(slug: 'test1', title: 'Test Article 1', summary: 'Test summary 1'), 13 | Dokno::Article.create!(slug: 'test2', title: 'Test Article 2', summary: 'Test summary 2') 14 | ] 15 | 16 | category = Dokno::Category.create!(name: 'Test Category') 17 | articles.last.categories << category 18 | 19 | visit dokno_path 20 | 21 | expect(page).to have_selector('#category') 22 | expect(page).to have_selector('#search_term') 23 | expect(page).to have_content(articles.first.title) 24 | expect(page).not_to have_content(articles.last.title) 25 | 26 | # Category index page 27 | within('#dokno-content-container') do 28 | select(category.name, from: 'category').select_option 29 | end 30 | 31 | expect(page).to have_content(articles.last.title) 32 | expect(page).not_to have_content(articles.first.title) 33 | 34 | # Search within a category 35 | within('#dokno-content-container') do 36 | fill_in 'search_term', with: 'test' 37 | end 38 | 39 | expect(page).to have_content('1 article') 40 | expect(page).to have_content('in this category') 41 | expect(page).to have_content(articles.last.title) 42 | expect(page).not_to have_content(articles.first.title) 43 | 44 | # Global search, not scoped to a category 45 | within('#dokno-content-container') do 46 | select('Uncategorized', from: 'category').select_option 47 | end 48 | 49 | expect(page).to have_content('2 articles') 50 | expect(page).to have_content(articles.first.title) 51 | expect(page).to have_content(articles.last.title) 52 | expect(page).not_to have_content('in this category') 53 | 54 | # Sorted alphabetically 55 | find('#dokno-order-link-alpha').click 56 | 57 | articles_on_page = all('a.dokno-article-title') 58 | 59 | expect(articles_on_page.length).to eq 2 60 | expect(articles_on_page.first.text).to eq articles.first.title 61 | expect(articles_on_page.last.text).to eq articles.last.title 62 | 63 | # Sorted by views 64 | articles.last.update!(views: 100) 65 | 66 | find('#dokno-order-link-views').click 67 | 68 | articles_on_page = all('a.dokno-article-title') 69 | 70 | expect(articles_on_page.first.text).to eq articles.last.title 71 | expect(articles_on_page.last.text).to eq articles.first.title 72 | 73 | # Sorted by last updated 74 | find('#dokno-order-link-updated').click 75 | 76 | articles_on_page = all('a.dokno-article-title') 77 | 78 | expect(articles_on_page.first.text).to eq articles.last.title 79 | expect(articles_on_page.last.text).to eq articles.first.title 80 | 81 | articles.first.update!(title: articles.first.title + 'changed') 82 | 83 | # Sort by last updated, after update 84 | find('#dokno-order-link-updated').click 85 | 86 | articles_on_page = all('a.dokno-article-title') 87 | 88 | expect(articles_on_page.first.text).to eq articles.first.title 89 | expect(articles_on_page.last.text).to eq articles.last.title 90 | end 91 | end 92 | -------------------------------------------------------------------------------- /spec/helpers/dokno/application_helper_spec.rb: -------------------------------------------------------------------------------- 1 | module Dokno 2 | describe ApplicationHelper, type: :helper do 3 | describe '#dokno_article_link' do 4 | it 'generates an in-context article link' do 5 | article = Article.create!(slug: 'slug', title: 'Test Title', summary: 'Test Summary', markdown: 'Test Markdown') 6 | 7 | # Without link text 8 | markup = dokno_article_link(slug: article.slug) 9 | expect(markup).to include "#{article.title}" 10 | 11 | # With link text 12 | markup = dokno_article_link('Test Link Text', slug: article.slug) 13 | expect(markup).to include "Test Link Text" 14 | end 15 | 16 | it 'shows appropriate message when the slug is not provided' do 17 | response = dokno_article_link 18 | expect(response).to include "Dokno article slug is required" 19 | end 20 | 21 | it 'shows appropriate message when the slug can not be found' do 22 | response = dokno_article_link(slug: 'bogus') 23 | expect(response).to include "Dokno article slug 'bogus' not found" 24 | end 25 | end 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /spec/models/dokno/article_spec.rb: -------------------------------------------------------------------------------- 1 | module Dokno 2 | describe Article, type: :model do 3 | let(:article) { Article.new } 4 | let(:valid_article) { Article.new(slug: 'slug', title: 'Test Title', summary: 'Test Summary', markdown: 'Test Markdown') } 5 | 6 | context 'validation' do 7 | it 'requires a slug and a title' do 8 | expect(article.validate).to be false 9 | expect(article.errors[:slug]).to include 'can\'t be blank' 10 | expect(article.errors[:title]).to include 'can\'t be blank' 11 | expect(Article.new(slug: 'dummy', title: 'dummy').valid?).to be true 12 | end 13 | 14 | it 'must have a unique slug' do 15 | articles = [Article.create(slug: 'dummy', title: 'dummy'), Article.create(slug: 'dummy2', title: 'dummy 2')] 16 | articles.first.slug = articles.last.slug 17 | 18 | expect(articles.first.validate).to be false 19 | expect(articles.first.errors[:slug]).to include "must be unique, #{articles.last.slug} has already been used" 20 | 21 | articles.first.slug = 'dummy3' 22 | expect(articles.first.validate).to be true 23 | end 24 | end 25 | 26 | context 'instance methods' do 27 | describe '#markdown' do 28 | it 'returns the markdown value or an empty string if nil' do 29 | expected_string = 'dummy' 30 | article.markdown = expected_string 31 | 32 | expect(article.markdown).to eq expected_string 33 | 34 | article.markdown = nil 35 | 36 | expect(article.markdown).to eq '' 37 | end 38 | end 39 | 40 | describe '#markdown_parsed' do 41 | it 'returns the markdown value markdown processed into HTML markup' do 42 | expected_string = 'this is **bold**' 43 | article.markdown = expected_string 44 | 45 | expect(article.markdown_parsed).to eq "

this is bold

\n" 46 | end 47 | end 48 | 49 | describe '#category_name_list' do 50 | it 'returns a string representation of the article category names' do 51 | categories = [Category.create(name: 'dummy 1'), Category.create(name: 'dummy 2')] 52 | valid_article.categories = categories 53 | valid_article.save 54 | 55 | expect(valid_article.category_name_list).to include 'dummy 1' 56 | expect(valid_article.category_name_list).to include 'dummy 2' 57 | end 58 | end 59 | 60 | describe '#host_panel_hash' do 61 | it 'returns a hash of the article attributes' do 62 | category = Category.create(name: 'dummy 1') 63 | valid_article.save 64 | valid_article.categories << category 65 | hash = valid_article.host_panel_hash 66 | 67 | # Fully populated article 68 | expect(hash[:id]).to eq valid_article.id 69 | expect(hash[:slug]).to eq valid_article.slug 70 | expect(hash[:title]).to include valid_article.title 71 | expect(hash[:summary]).to eq valid_article.summary 72 | expect(hash[:markdown]).to include valid_article.markdown 73 | expect(hash[:footer]).to include valid_article.slug 74 | expect(hash[:footer]).to include category.name 75 | expect(hash[:title]).not_to include 'This article is no longer active' 76 | 77 | # Article without summary, but with markdown 78 | valid_article.summary = '' 79 | expect(valid_article.host_panel_hash[:summary]).to be_empty 80 | 81 | # Article missing summary and markdown 82 | valid_article.markdown = '' 83 | expect(valid_article.host_panel_hash[:summary]).to eq 'No content' 84 | 85 | # Deactivated article 86 | valid_article.active = false 87 | expect(valid_article.host_panel_hash[:title]).to include 'This article is no longer active' 88 | end 89 | end 90 | 91 | describe '#permalink' do 92 | it 'returns the article permalink' do 93 | expect(valid_article.permalink('dummy_base_path')).to eq 'dummy_base_path/help/articles/slug' 94 | end 95 | end 96 | 97 | describe '#log_changes' do 98 | it 'appropriately logs article data changes' do 99 | original_attrs = valid_article.attributes 100 | valid_article.save 101 | valid_article.update!( 102 | reset_review_date: true, 103 | active: false, 104 | review_notes: Faker::Lorem.paragraph, 105 | slug: valid_article.slug + 'new', 106 | title: valid_article.title + 'new', 107 | summary: valid_article.summary + 'new', 108 | markdown: valid_article.markdown + 'new' 109 | ) 110 | 111 | expect(valid_article.logs.count).to eq 3 112 | expect(valid_article.logs.second.meta).to eq "Slug changed from 'slug' to 'slugnew', "\ 113 | "Title changed from 'Test Title' to 'Test Titlenew', Active changed from 'true' to "\ 114 | "'false', Summary was changed, and Markdown was changed" 115 | expect(valid_article.logs.second.diff_left).to include "
  • "\ 116 | "#{original_attrs['summary']} #{original_attrs['markdown']}
  • " 117 | expect(valid_article.logs.second.diff_right).to include "
  • "\ 118 | "#{original_attrs['summary']}new #{original_attrs['markdown']}new
  • " 119 | 120 | expect(valid_article.logs.third.meta).to include "Reviewed for accuracy / relevance. "\ 121 | "Next review date reset to #{valid_article.review_due_at.to_date}." 122 | 123 | expect(valid_article.logs.third.meta).to include valid_article.review_notes 124 | end 125 | end 126 | 127 | describe '#reading_time' do 128 | it 'calculates and returns the approximate reading time for an article' do 129 | expect(article.reading_time).to be_blank 130 | 131 | # Only returns reading time if > ~1 minute 132 | article.summary = 'word ' * 10 133 | expect(article.reading_time).to be_blank 134 | 135 | article.summary = 'word ' * 1_000 136 | expect(article.reading_time).to eq '~ 5 minutes' 137 | 138 | article.markdown = 'word ' * 1_000 139 | expect(article.reading_time).to eq '~ 10 minutes' 140 | end 141 | end 142 | end 143 | 144 | context 'class methods' do 145 | describe '.uncategorized' do 146 | it 'returns all articles not assigned to a category' do 147 | valid_article.save 148 | 149 | expect(Article.uncategorized.count).to eq 1 150 | expect(Article.uncategorized).to include valid_article 151 | 152 | category = Category.create(name: 'dummy 1') 153 | valid_article.categories << category 154 | 155 | expect(Article.uncategorized.count).to eq 0 156 | end 157 | end 158 | end 159 | end 160 | end 161 | -------------------------------------------------------------------------------- /spec/models/dokno/category_spec.rb: -------------------------------------------------------------------------------- 1 | module Dokno 2 | describe Category, type: :model do 3 | let(:category) { Category.new } 4 | let(:valid_category) { Category.new(name: 'Test Category') } 5 | 6 | context 'validation' do 7 | it 'requires a name' do 8 | expect(category.validate).to be false 9 | expect(category.errors[:name]).to include 'can\'t be blank' 10 | expect(Category.new(name: 'dummy').valid?).to be true 11 | end 12 | 13 | it 'must have a unique name' do 14 | categories = [Category.create(name: 'dummy'), Category.create(name: 'dummy 2')] 15 | categories.first.name = categories.last.name 16 | 17 | expect(categories.first.validate).to be false 18 | expect(categories.first.errors[:name]).to include 'has already been taken' 19 | 20 | categories.first.name = 'dummy 3' 21 | expect(categories.first.validate).to be true 22 | end 23 | 24 | it 'can not have its parent set to self' do 25 | categories = [Category.create(name: 'dummy'), Category.create(name: 'dummy 2')] 26 | categories.first.category_id = categories.first.id 27 | 28 | expect(categories.first.validate).to be false 29 | expect(categories.first.errors[:category_id]).to include 'can\'t set parent to self' 30 | 31 | categories.first.category_id = categories.last.id 32 | expect(categories.first.validate).to be true 33 | end 34 | end 35 | 36 | context 'instance methods' do 37 | describe '#breadcrumb' do 38 | it 'returns the breadcrumb for the nested category' do 39 | valid_category.save 40 | parent_category = Category.create!(name: 'Test Parent Category') 41 | parent_parent_category = Category.create!(name: 'Test Parent Parent Category') 42 | 43 | parent_category.update!(category_id: parent_parent_category.id) 44 | valid_category.update!(category_id: parent_category.id) 45 | 46 | breadcrumb = valid_category.breadcrumb 47 | 48 | expect(breadcrumb).to include parent_parent_category.name 49 | expect(breadcrumb).to include parent_category.name 50 | expect(breadcrumb).to include valid_category.name 51 | end 52 | end 53 | 54 | describe '#articles_in_branch' do 55 | it 'returns the articles assigned to the category or any children categories' do 56 | valid_category.save 57 | child_category = Category.create!(name: 'Test Child Category') 58 | article = Article.create!(slug: 'slug', title: 'Test Title') 59 | another_article = Article.create!(slug: 'slug2', title: 'Test Title 2') 60 | child_category.update!(category_id: valid_category.id) 61 | 62 | expect(valid_category.articles_in_branch).to be_empty 63 | 64 | valid_category.articles << article 65 | child_category.articles << another_article 66 | 67 | expect(valid_category.articles_in_branch.count).to eq 2 68 | expect(valid_category.articles_in_branch).to include article 69 | expect(valid_category.articles_in_branch).to include another_article 70 | 71 | expect(child_category.articles_in_branch.count).to eq 1 72 | expect(child_category.articles_in_branch).to include another_article 73 | end 74 | end 75 | end 76 | 77 | context 'class methods' do 78 | describe '.select_option_markup' do 79 | it 'builds and returns the category select option markup' do 80 | valid_category.save! 81 | categories = [valid_category, Category.create!(name: 'Test Category 2'), Category.create!(name: 'Test Category 3')] 82 | selected_category = categories.pop 83 | excluded_category = categories.sample 84 | 85 | select_option_markup = Category.select_option_markup 86 | categories.each do |category| 87 | expect(select_option_markup).to include category.code 88 | expect(select_option_markup).to include category.name 89 | end 90 | 91 | select_option_markup = Category.select_option_markup(selected_category_codes: [selected_category.code]) 92 | expect(select_option_markup).to include "" 93 | expect(select_option_markup).to include "\"#{excluded_category.code}\"" 94 | end 95 | end 96 | 97 | describe '.cache_key' do 98 | it 'returns the updated_at timestamp of the most-recently updated category or article' do 99 | article = Article.create!(slug: 'testslug', title: 'Test Title', created_at: 1.day.ago, updated_at: 1.day.ago) 100 | valid_category.save! 101 | 102 | expected_cache_key = valid_category.updated_at 103 | 104 | expect(Category.cache_key).to eq expected_cache_key 105 | 106 | valid_category.update!(name: valid_category.name + 'changed') 107 | 108 | expect(Category.cache_key).not_to eq expected_cache_key 109 | expect(Category.cache_key).to eq valid_category.updated_at 110 | 111 | article.update!(title: article.title + 'changed') 112 | 113 | expect(Category.cache_key).not_to eq valid_category.updated_at 114 | expect(Category.cache_key).to eq article.updated_at 115 | end 116 | end 117 | end 118 | end 119 | end 120 | -------------------------------------------------------------------------------- /spec/rails_helper.rb: -------------------------------------------------------------------------------- 1 | # This file is copied to spec/ when you run 'rails generate rspec:install' 2 | ENV['RAILS_ENV'] ||= 'test' 3 | 4 | require File.expand_path('./dummy/config/environment', __dir__) 5 | 6 | # Prevent database truncation if the environment is production 7 | abort("The Rails environment is running in production mode!") if Rails.env.production? 8 | 9 | require 'rspec/rails' 10 | # Add additional requires below this line. Rails is not loaded until this point! 11 | 12 | # Requires supporting ruby files with custom matchers and macros, etc, in 13 | # spec/support/ and its subdirectories. Files matching `spec/**/*_spec.rb` are 14 | # run as spec files by default. This means that files in spec/support that end 15 | # in _spec.rb will both be required and run as specs, causing the specs to be 16 | # run twice. It is recommended that you do not name files matching this glob to 17 | # end with _spec.rb. You can configure this pattern with the --pattern 18 | # option on the command line or in ~/.rspec, .rspec or `.rspec-local`. 19 | # 20 | # The following line is provided for convenience purposes. It has the downside 21 | # of increasing the boot-up time by auto-requiring all files in the support 22 | # directory. Alternatively, in the individual `*_spec.rb` files, manually 23 | # require only the support files necessary. 24 | # 25 | # Dir[Rails.root.join('spec', 'support', '**', '*.rb')].sort.each { |f| require f } 26 | 27 | # Checks for pending migrations and applies them before tests are run. 28 | # If you are not using ActiveRecord, you can remove these lines. 29 | begin 30 | ActiveRecord::Migration.maintain_test_schema! 31 | rescue ActiveRecord::PendingMigrationError => _e 32 | ActiveRecord::MigrationContext.new("db/migrate/", ActiveRecord::SchemaMigration).migrate 33 | end 34 | 35 | RSpec.configure do |config| 36 | # Remove this line if you're not using ActiveRecord or ActiveRecord fixtures 37 | # config.fixture_path = "#{::Rails.root}/spec/fixtures" 38 | 39 | # If you're not using ActiveRecord, or you'd prefer not to run each of your 40 | # examples within a transaction, remove the following line or assign false 41 | # instead of true. 42 | config.use_transactional_fixtures = true 43 | 44 | # You can uncomment this line to turn off ActiveRecord support entirely. 45 | # config.use_active_record = false 46 | 47 | # RSpec Rails can automatically mix in different behaviours to your tests 48 | # based on their file location, for example enabling you to call `get` and 49 | # `post` in specs under `spec/controllers`. 50 | # 51 | # You can disable this behaviour by removing the line below, and instead 52 | # explicitly tag your specs with their type, e.g.: 53 | # 54 | # RSpec.describe UsersController, type: :controller do 55 | # # ... 56 | # end 57 | # 58 | # The different available types are documented in the features, such as in 59 | # https://relishapp.com/rspec/rspec-rails/docs 60 | # config.infer_spec_type_from_file_location! 61 | 62 | # Filter lines from Rails gems in backtraces. 63 | config.filter_rails_from_backtrace! 64 | # arbitrary gems may also be filtered via: 65 | # config.filter_gems_from_backtrace("gem name") 66 | end 67 | -------------------------------------------------------------------------------- /spec/requests/dokno/application_spec.rb: -------------------------------------------------------------------------------- 1 | # Specs shared across controllers 2 | module Dokno 3 | describe ApplicationController, type: :controller do 4 | describe '#user' do 5 | context 'proper host app user configuration' do 6 | it 'returns the evaluated user object from the host app' do 7 | expect(subject.user).to be_kind_of User 8 | expect(subject.user).to respond_to Dokno.config.app_user_name_method.to_sym 9 | expect(subject.user).to respond_to Dokno.config.app_user_auth_method.to_sym 10 | end 11 | end 12 | 13 | context 'improper host app user configuration' do 14 | it 'returns nil' do 15 | allow(Dokno.config).to receive(:app_user_object).and_return('bogus') 16 | expect(subject.user).to be_nil 17 | end 18 | end 19 | end 20 | 21 | describe '#username' do 22 | context 'proper host app user configuration' do 23 | it 'returns the identifying string for the authenticated user in the host app' do 24 | app_user_name_method = :name 25 | expected_user_name = 'Dummy User' 26 | 27 | allow(Dokno.config).to receive(:app_user_name_method).and_return(app_user_name_method) 28 | allow(subject.user).to receive(Dokno.config.app_user_name_method.to_sym).and_return(expected_user_name) 29 | 30 | expect(subject.username).to eq expected_user_name 31 | end 32 | end 33 | 34 | context 'improper host app user configuration' do 35 | it 'returns an empty string' do 36 | allow(Dokno.config).to receive(:app_user_object).and_return('bogus') 37 | expect(subject.username).to eq '' 38 | end 39 | end 40 | end 41 | 42 | describe '#can_edit?' do 43 | context 'proper host app user configuration' do 44 | it 'indicates whether the authenticated host app user has edit permissions' do 45 | app_user_auth_method = :admin? 46 | 47 | allow(Dokno.config).to receive(:app_user_auth_method).and_return(app_user_auth_method) 48 | allow(subject.user).to receive(Dokno.config.app_user_auth_method.to_sym).and_return(true) 49 | expect(subject.can_edit?).to be true 50 | 51 | allow(subject.user).to receive(Dokno.config.app_user_auth_method.to_sym).and_return(false) 52 | expect(subject.can_edit?).to be false 53 | end 54 | end 55 | 56 | context 'improper host app user configuration' do 57 | it 'indicates that the authenticated host app user does not have edit permissions' do 58 | allow(Dokno.config).to receive(:app_user_object).and_return('bogus') 59 | expect(subject.can_edit?).to be false 60 | end 61 | end 62 | 63 | context 'host app user setting not configured' do 64 | it 'indicates that the authenticated host app user has edit permissions by default' do 65 | allow(Dokno.config).to receive(:app_user_object).and_return('') 66 | expect(subject.can_edit?).to be true 67 | end 68 | end 69 | end 70 | end 71 | end 72 | -------------------------------------------------------------------------------- /spec/requests/dokno/article_spec.rb: -------------------------------------------------------------------------------- 1 | module Dokno 2 | describe 'Articles', type: :request do 3 | let!(:article) { Article.create!(slug: 'slug', title: 'Test Title', summary: 'Test Summary', markdown: 'Test Markdown') } 4 | let(:days_out) { Dokno.config.article_review_prompt_days } 5 | 6 | describe '#show' do 7 | context 'standard article' do 8 | it 'returns an article show page' do 9 | get dokno.article_path(article) 10 | 11 | # Testing that accessing the article via ID redirects to the article permalink via its slug 12 | expect(response).to redirect_to(dokno.article_path(article.slug)) 13 | 14 | follow_redirect! 15 | 16 | expect(response.body).to include article.slug 17 | expect(response.body).to include article.title 18 | expect(response.body).to include article.summary 19 | expect(response.body).to include article.markdown_parsed 20 | end 21 | end 22 | 23 | context 'special case articles' do 24 | let(:inactive_article) { Article.create!(slug: 'slug2', title: 'Test Title 2', active: false) } 25 | let(:up_for_review_article) { Article.create!(slug: 'slug3', title: 'Test Title 3', review_due_at: Date.today + days_out.days) } 26 | let(:past_due_review_article) { Article.create!(slug: 'slug4', title: 'Test Title 4', review_due_at: Date.today - 1.day) } 27 | let(:review_due_today_article) { Article.create!(slug: 'slug5', title: 'Test Title 5', review_due_at: Date.today) } 28 | 29 | it 'returns an article show page that is up for review' do 30 | get dokno.article_path(up_for_review_article.slug) 31 | expect(response.body.squish).to include up_for_review_article.review_due_days_string 32 | end 33 | 34 | it 'returns an article show page that is up for review today' do 35 | get dokno.article_path(review_due_today_article.slug) 36 | expect(response.body.squish).to include review_due_today_article.review_due_days_string 37 | end 38 | 39 | it 'returns an article show page that is past due for review' do 40 | get dokno.article_path(past_due_review_article.slug) 41 | expect(response.body.squish).to include past_due_review_article.review_due_days_string 42 | end 43 | 44 | it 'returns an article show page that is inactive' do 45 | get dokno.article_path(inactive_article.slug) 46 | expect(response.body.squish).to include 'This article is no longer active' 47 | end 48 | end 49 | end 50 | 51 | describe '#new' do 52 | it 'returns the new article form' do 53 | get dokno.new_article_path 54 | 55 | expect(response.code).to eq '200' 56 | expect(response.body).to include "New Article" 57 | expect(response.body).to include "
    " 62 | expect(response.body).to include "id=\"#{field_id}\"" 63 | expect(response.body).to include "name=\"#{field_id}\"" 64 | end 65 | end 66 | 67 | it 'presents the article template' do 68 | template_file = File.read(File.join(Rails.root, 'config', 'dokno_template.md')) 69 | 70 | get dokno.new_article_path 71 | 72 | expect(template_file.present?).to be true 73 | expect(response.body).to include template_file.split('.').first 74 | end 75 | end 76 | 77 | describe '#edit' do 78 | it 'returns the edit article form' do 79 | get dokno.edit_article_path(article.slug) 80 | 81 | expect(response.code).to eq '200' 82 | expect(response.body).to include "Edit Article" 83 | expect(response.body).to include "" 88 | expect(response.body).to include "id=\"#{field_id}\"" 89 | expect(response.body).to include "name=\"#{field_id}\"" 90 | 91 | expect(response.body).to include article.send(field_id.to_sym) 92 | end 93 | end 94 | end 95 | 96 | describe '#create' do 97 | it 'creates a new article instance' do 98 | categories = [Category.create!(name: 'Test Category'), Category.create!(name: 'Test Category 2')] 99 | attrs = { 100 | slug: 'testslug', 101 | title: 'Test Title', 102 | summary: 'Test Summary', 103 | markdown: 'Test Markdown', 104 | starred: true 105 | } 106 | 107 | expect do 108 | post dokno.articles_path, params: { category_code: categories.map(&:code) }.merge(attrs) 109 | end.to change(Article, :count).by(1) 110 | 111 | follow_redirect! 112 | expect(response.body).to include 'Article was created' 113 | 114 | new_article = Article.find_by(slug: 'testslug') 115 | 116 | expect(new_article.present?).to be true 117 | expect(new_article.categories.count).to eq 2 118 | expect(new_article.categories).to include categories.first 119 | expect(new_article.categories).to include categories.last 120 | 121 | persisted_attrs = new_article.attributes.slice('slug', 'title', 'summary', 'markdown', 'starred').symbolize_keys 122 | expect(persisted_attrs).to eq attrs 123 | end 124 | 125 | it 'does not create an invalid article instance' do 126 | expect do 127 | post dokno.articles_path, params: { slug: 'invalidbecausetoolongtopassvalidation' } 128 | end.to change(Article, :count).by(0) 129 | 130 | expect(response.body).to include "Title can't be blank" 131 | expect(response.body).to include "Title is too short (minimum is 5 characters)" 132 | expect(response.body).to include "Slug is too long (maximum is 20 characters)" 133 | end 134 | end 135 | 136 | describe '#update' do 137 | it 'updates an article instance' do 138 | categories = [Category.create!(name: 'Test Category'), Category.create!(name: 'Test Category 2')] 139 | attrs = { 140 | slug: article.slug + 'new', 141 | title: article.title + 'new', 142 | summary: article.summary + 'new', 143 | markdown: article.markdown + 'new', 144 | starred: true 145 | } 146 | 147 | expect do 148 | patch dokno.article_path(article), params: { category_code: categories.map(&:code) }.merge(attrs) 149 | end.to change(Article, :count).by(0) 150 | 151 | follow_redirect! 152 | expect(response.body).to include 'Article was updated' 153 | 154 | updated_article = Article.find_by(slug: article.slug + 'new') 155 | 156 | expect(updated_article.present?).to be true 157 | expect(updated_article.categories.count).to eq 2 158 | expect(updated_article.categories).to include categories.first 159 | expect(updated_article.categories).to include categories.last 160 | 161 | persisted_attrs = updated_article.attributes.slice('slug', 'title', 'summary', 'markdown', 'starred').symbolize_keys 162 | expect(persisted_attrs).to eq attrs 163 | end 164 | 165 | it 'does not update an article instance if invalid' do 166 | expect do 167 | patch dokno.article_path(article), params: { slug: 'invalidbecausetoolongtopassvalidation' } 168 | end.to change(Article, :count).by(0) 169 | 170 | expect(response.body).to include "Slug is too long (maximum is 20 characters)" 171 | end 172 | end 173 | 174 | describe '#destroy' do 175 | it 'deletes an article instance' do 176 | expect do 177 | delete dokno.article_path(article) 178 | end.to change(Article, :count).by(-1) 179 | end 180 | end 181 | 182 | describe '#panel' do 183 | it 'returns the article in-context panel hash' do 184 | get dokno.panel_path(article.slug), xhr: true 185 | 186 | response_hash = JSON.parse(response.body).symbolize_keys 187 | 188 | expect(response.code).to eq '200' 189 | expect(response_hash[:id]).to eq article.id 190 | expect(response_hash[:title]).to include article.title 191 | expect(response_hash[:summary]).to include article.summary 192 | expect(response_hash[:markdown]).to include article.markdown_parsed 193 | end 194 | end 195 | 196 | describe '#preview' do 197 | it 'parses the provided markdown and returns the resulting markup' do 198 | post dokno.preview_path, params: { markdown: 'this should be **bold**' }, xhr: true 199 | 200 | response_hash = JSON.parse(response.body).symbolize_keys 201 | 202 | expect(response.code).to eq '200' 203 | expect(response_hash[:parsed_content]).to include "

    this should be bold

    " 204 | end 205 | end 206 | 207 | describe '#status' do 208 | it 'changes an article instance active status' do 209 | expect(article.active).to be true 210 | 211 | post dokno.status_path, params: { slug: article.slug, active: false }, xhr: true 212 | 213 | expect(response.code).to eq '200' 214 | expect(article.reload.active).to be false 215 | 216 | post dokno.status_path, params: { slug: article.slug, active: true }, xhr: true 217 | 218 | expect(response.code).to eq '200' 219 | expect(article.reload.active).to be true 220 | end 221 | end 222 | end 223 | end 224 | -------------------------------------------------------------------------------- /spec/requests/dokno/category_spec.rb: -------------------------------------------------------------------------------- 1 | module Dokno 2 | describe 'Categories', type: :request do 3 | let!(:category) { Category.create!(name: 'Test Category') } 4 | 5 | describe '#index' do 6 | it 'returns articles that are up for review' do 7 | days_out = Dokno.config.article_review_prompt_days 8 | article_not_up_for_review = Article.create!(slug: 'slug1', title: 'Test Title 1', review_due_at: Date.today + (days_out + 1).days) 9 | article_up_for_review = Article.create!(slug: 'slug2', title: 'Test Title 2', review_due_at: Date.today + days_out.days) 10 | article_past_due = Article.create!(slug: 'slug3', title: 'Test Title 3', review_due_at: Date.today - 1.day) 11 | 12 | get dokno.up_for_review_path 13 | 14 | expect(response.body).to include article_up_for_review.title 15 | expect(response.body).to include article_past_due.title 16 | expect(response.body).not_to include article_not_up_for_review.title 17 | expect(response.body.squish).to include "2 articles up for review" 18 | expect(response.body).to include "This article was up for an accuracy / relevance review 1 days ago" 19 | expect(response.body).to include "This article is up for an accuracy / relevance review in #{days_out} days" 20 | end 21 | 22 | it 'returns search results (all articles)' do 23 | article = Article.create!(slug: 'slug', title: 'Test Title', summary: 'Test Summary banana', markdown: 'Test Markdown') 24 | 25 | get "#{dokno.root_path}?search_term=#{CGI.escape('summary banana')}" 26 | 27 | expect(response.code).to eq '200' 28 | expect(response.body).to include "1 article" 29 | expect(response.body).to include "found containing the search term" 30 | expect(response.body).to include " summary banana " 31 | expect(response.body).to include article.title 32 | expect(response.body).to include article.summary 33 | 34 | get "#{dokno.root_path}?search_term=#{CGI.escape('string that is not there')}" 35 | 36 | expect(response.code).to eq '200' 37 | expect(response.body).to include "No articles" 38 | expect(response.body).to include "found containing the search term" 39 | expect(response.body).to include " string that is not there " 40 | expect(response.body).to include "No articles found" 41 | 42 | # Should return categorized articles as well 43 | article.categories << category 44 | 45 | get "#{dokno.root_path}?search_term=#{CGI.escape('summary banana')}" 46 | 47 | expect(response.body).to include "1 article" 48 | expect(response.body).to include "found containing the search term" 49 | expect(response.body).to include article.title 50 | expect(response.body).to include article.summary 51 | end 52 | 53 | it 'returns search results (category scoped)' do 54 | another_category = Category.create!(name: 'Another Category') 55 | article = Article.create!(slug: 'slug', title: 'Test Title', summary: 'Test Summary banana', markdown: 'Test Markdown') 56 | article.categories << category 57 | 58 | get "#{dokno.article_index_path(another_category.code)}?search_term=#{CGI.escape('summary banana')}" 59 | 60 | expect(response.code).to eq '200' 61 | expect(response.body).to include "No articles" 62 | expect(response.body).to include "found containing the search term" 63 | expect(response.body).to include " summary banana " 64 | expect(response.body).to include "No articles found in this category matching the given search criteria" 65 | 66 | get "#{dokno.article_index_path(category.code)}?search_term=#{CGI.escape('summary banana')}" 67 | 68 | expect(response.body).to include "1 article" 69 | expect(response.body).to include "found containing the search term" 70 | expect(response.body).to include " summary banana " 71 | expect(response.body).to include article.title 72 | expect(response.body).to include article.summary 73 | end 74 | 75 | it 'returns the landing page (including uncategorized articles)' do 76 | article = Article.create!(slug: 'slug', title: 'Test Title', summary: 'Test Summary', markdown: 'Test Markdown') 77 | 78 | get dokno.root_path 79 | 80 | expect(response.code).to eq '200' 81 | expect(response.body).to include "name=\"category\" id=\"category\"" 82 | expect(response.body).to include "name=\"search_term\" id=\"search_term\"" 83 | expect(response.body).to include category.code 84 | expect(response.body).to include category.name 85 | 86 | # Includes uncategorized article 87 | expect(response.body).to include article.title 88 | expect(response.body).to include article.summary 89 | end 90 | 91 | it 'returns a category index page' do 92 | article = Article.create!(slug: 'slug', title: 'Test Title', summary: 'Test Summary', markdown: 'Test Markdown') 93 | 94 | get "#{dokno.article_index_path(category.code)}?" 95 | 96 | expect(response.code).to eq '200' 97 | expect(response.body).to include "name=\"category\" id=\"category\"" 98 | expect(response.body).to include "name=\"search_term\" id=\"search_term\"" 99 | expect(response.body).to include category.code 100 | expect(response.body).to include category.name 101 | 102 | # Does not include the categorized article 103 | expect(response.body).not_to include article.title 104 | expect(response.body).not_to include article.summary 105 | 106 | article.categories << category 107 | 108 | get "#{dokno.article_index_path(category.code)}?" 109 | 110 | # Now includes the categorized article 111 | expect(response.body).to include article.title 112 | expect(response.body).to include article.summary 113 | end 114 | end 115 | 116 | describe '#new' do 117 | it 'returns the new category form' do 118 | get dokno.new_category_path 119 | 120 | expect(response.code).to eq '200' 121 | expect(response.body).to include "New Category" 122 | expect(response.body).to include "" 127 | expect(response.body).to include "id=\"#{field_id}\"" 128 | expect(response.body).to include "name=\"#{field_id}\"" 129 | end 130 | end 131 | end 132 | 133 | describe '#edit' do 134 | it 'returns the edit category form' do 135 | parent_category = Category.create!(name: 'Test Parent Category') 136 | category.update!(category_id: parent_category.id) 137 | 138 | get dokno.edit_category_path(category) 139 | 140 | expect(response.code).to eq '200' 141 | expect(response.body).to include "Edit Category" 142 | expect(response.body).to include "" 147 | expect(response.body).to include "id=\"#{field_id}\"" 148 | expect(response.body).to include "name=\"#{field_id}\"" 149 | end 150 | 151 | expect(response.body).to include category.name 152 | end 153 | end 154 | 155 | describe '#create' do 156 | it 'creates a new category instance' do 157 | parent_category = Category.create!(name: 'Test Parent Category') 158 | 159 | expect do 160 | post dokno.categories_path, params: { name: 'Created Category', parent_category_code: parent_category.code } 161 | end.to change(Category, :count).by(1) 162 | 163 | follow_redirect! 164 | expect(response.body).to include 'Category was created' 165 | 166 | new_category = Category.find_by(name: 'Created Category') 167 | 168 | expect(new_category.present?).to be true 169 | expect(new_category.parent).to eq parent_category 170 | end 171 | 172 | it 'does not create an invalid category instance' do 173 | expect do 174 | post dokno.categories_path, params: { name: '' } 175 | end.to change(Category, :count).by(0) 176 | 177 | expect(response.body).to include "Name can't be blank" 178 | end 179 | end 180 | 181 | describe '#update' do 182 | it 'updates a category instance' do 183 | parent_category = Category.create!(name: 'Test Parent Category') 184 | 185 | expect do 186 | patch dokno.category_path(category), params: { name: category.name + 'new', parent_category_code: parent_category.code } 187 | end.to change(Category, :count).by(0) 188 | 189 | follow_redirect! 190 | expect(response.body).to include 'Category was updated' 191 | 192 | updated_category = Category.find_by(name: category.name + 'new') 193 | 194 | expect(updated_category.present?).to be true 195 | expect(updated_category.parent).to eq parent_category 196 | end 197 | 198 | it 'does not update a category instance if invalid' do 199 | expect do 200 | patch dokno.category_path(category), params: { name: '' } 201 | end.to change(Category, :count).by(0) 202 | 203 | expect(response.body).to include "Name can't be blank" 204 | end 205 | end 206 | 207 | describe '#destroy' do 208 | it 'deletes a category instance' do 209 | expect do 210 | delete dokno.category_path(category) 211 | end.to change(Category, :count).by(-1) 212 | end 213 | end 214 | end 215 | end 216 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | # This file was generated by the `rails generate rspec:install` command. Conventionally, all 2 | # specs live under a `spec` directory, which RSpec adds to the `$LOAD_PATH`. 3 | # The generated `.rspec` file contains `--require spec_helper` which will cause 4 | # this file to always be loaded, without a need to explicitly require it in any 5 | # files. 6 | # 7 | # Given that it is always loaded, you are encouraged to keep this file as 8 | # light-weight as possible. Requiring heavyweight dependencies from this file 9 | # will add to the boot time of your test suite on EVERY test run, even for an 10 | # individual file that may not need all of that loaded. Instead, consider making 11 | # a separate helper file that requires the additional dependencies and performs 12 | # the additional setup, and require it from the spec files that actually need 13 | # it. 14 | # 15 | # See http://rubydoc.info/gems/rspec-core/RSpec/Core/Configuration 16 | 17 | ENV["RAILS_ENV"] = 'test' 18 | 19 | require 'simplecov' 20 | SimpleCov.start :rails do 21 | add_filter 'lib/' 22 | end 23 | 24 | require 'rails_helper' 25 | require 'database_cleaner/active_record' 26 | require 'pry' 27 | require 'capybara/rails' 28 | require 'capybara/rspec' 29 | require 'faker' 30 | 31 | Capybara.register_driver :chrome do |app| 32 | Capybara::Selenium::Driver.new(app, browser: :chrome) 33 | end 34 | Capybara.server = :puma 35 | Capybara.default_driver = :chrome 36 | Capybara.javascript_driver = :chrome 37 | 38 | RSpec.configure do |config| 39 | # Don't run slow js specs on GitHub CI builds 40 | if ENV['SKIP_JS'].present? || ENV['GITHUB_WORKFLOW'].present? 41 | config.filter_run_excluding js: true 42 | end 43 | 44 | config.order = :random 45 | 46 | # rspec-expectations config goes here. You can use an alternate 47 | # assertion/expectation library such as wrong or the stdlib/minitest 48 | # assertions if you prefer. 49 | config.expect_with :rspec do |expectations| 50 | # This option will default to `true` in RSpec 4. It makes the `description` 51 | # and `failure_message` of custom matchers include text for helper methods 52 | # defined using `chain`, e.g.: 53 | # be_bigger_than(2).and_smaller_than(4).description 54 | # # => "be bigger than 2 and smaller than 4" 55 | # ...rather than: 56 | # # => "be bigger than 2" 57 | expectations.include_chain_clauses_in_custom_matcher_descriptions = true 58 | end 59 | 60 | # rspec-mocks config goes here. You can use an alternate test double 61 | # library (such as bogus or mocha) by changing the `mock_with` option here. 62 | config.mock_with :rspec do |mocks| 63 | # Prevents you from mocking or stubbing a method that does not exist on 64 | # a real object. This is generally recommended, and will default to 65 | # `true` in RSpec 4. 66 | mocks.verify_partial_doubles = true 67 | end 68 | 69 | # This option will default to `:apply_to_host_groups` in RSpec 4 (and will 70 | # have no way to turn it off -- the option exists only for backwards 71 | # compatibility in RSpec 3). It causes shared context metadata to be 72 | # inherited by the metadata hash of host groups and examples, rather than 73 | # triggering implicit auto-inclusion in groups with matching metadata. 74 | config.shared_context_metadata_behavior = :apply_to_host_groups 75 | config.include Rails.application.routes.url_helpers 76 | config.include Capybara::DSL 77 | config.filter_run focus: true 78 | config.run_all_when_everything_filtered = true 79 | config.example_status_persistence_file_path = './rspec_failures.txt' 80 | 81 | config.before(:suite) do 82 | DatabaseCleaner.clean_with(:truncation) 83 | end 84 | 85 | config.before(:each) do 86 | DatabaseCleaner.strategy = :transaction 87 | end 88 | 89 | config.before(:each, js: true) do 90 | DatabaseCleaner.strategy = :truncation 91 | end 92 | 93 | config.before(:each) do 94 | DatabaseCleaner.start 95 | end 96 | 97 | config.after(:each) do 98 | DatabaseCleaner.clean 99 | end 100 | 101 | # The settings below are suggested to provide a good initial experience 102 | # with RSpec, but feel free to customize to your heart's content. 103 | =begin 104 | # This allows you to limit a spec run to individual examples or groups 105 | # you care about by tagging them with `:focus` metadata. When nothing 106 | # is tagged with `:focus`, all examples get run. RSpec also provides 107 | # aliases for `it`, `describe`, and `context` that include `:focus` 108 | # metadata: `fit`, `fdescribe` and `fcontext`, respectively. 109 | config.filter_run_when_matching :focus 110 | 111 | # Allows RSpec to persist some state between runs in order to support 112 | # the `--only-failures` and `--next-failure` CLI options. We recommend 113 | # you configure your source control system to ignore this file. 114 | config.example_status_persistence_file_path = "spec/examples.txt" 115 | 116 | # Limits the available syntax to the non-monkey patched syntax that is 117 | # recommended. For more details, see: 118 | # - http://rspec.info/blog/2012/06/rspecs-new-expectation-syntax/ 119 | # - http://www.teaisaweso.me/blog/2013/05/27/rspecs-new-message-expectation-syntax/ 120 | # - http://rspec.info/blog/2014/05/notable-changes-in-rspec-3/#zero-monkey-patching-mode 121 | config.disable_monkey_patching! 122 | 123 | # Many RSpec users commonly either run the entire suite or an individual 124 | # file, and it's useful to allow more verbose output when running an 125 | # individual spec file. 126 | if config.files_to_run.one? 127 | # Use the documentation formatter for detailed output, 128 | # unless a formatter has already been configured 129 | # (e.g. via a command-line flag). 130 | config.default_formatter = "doc" 131 | end 132 | 133 | # Print the 10 slowest examples and example groups at the 134 | # end of the spec run, to help surface which specs are running 135 | # particularly slow. 136 | config.profile_examples = 10 137 | 138 | # Run specs in random order to surface order dependencies. If you find an 139 | # order dependency and want to debug it, you can fix the order by providing 140 | # the seed, which is printed after each run. 141 | # --seed 1234 142 | config.order = :random 143 | 144 | # Seed global randomization in this process using the `--seed` CLI option. 145 | # Setting this allows you to use `--seed` to deterministically reproduce 146 | # test failures related to randomization by passing the same `--seed` value 147 | # as the one that triggered the failure. 148 | Kernel.srand config.seed 149 | =end 150 | end 151 | --------------------------------------------------------------------------------