├── .github ├── FUNDING.yml ├── dependabot.yml └── workflows │ ├── automerge.yml │ ├── lint.yml │ ├── tag_and_release.yml │ └── test.yml ├── .gitignore ├── .rubocop.yml ├── .ruby-version ├── .vscode └── settings.json ├── CHANGELOG.md ├── Gemfile ├── LICENSE.txt ├── README.md ├── Rakefile ├── jekyll-last-modified-at.gemspec ├── lib ├── jekyll-last-modified-at.rb └── jekyll-last-modified-at │ ├── determinator.rb │ ├── executor.rb │ ├── git.rb │ ├── hook.rb │ ├── tag.rb │ └── version.rb ├── script ├── bootstrap └── cibuild └── spec ├── dev └── .gitkeep ├── fixtures ├── _config.yml ├── _layouts │ ├── last_modified_at.html │ └── last_modified_at_with_format.html ├── _posts │ ├── 1984-03-06-command.md │ ├── 1984-03-06-last-modified-at-with-format.md │ └── 1984-03-06-last-modified-at.md └── file.txt ├── jekyll-last-modified-at ├── determinator_spec.rb ├── executor_spec.rb └── tag_spec.rb ├── plugins └── last_modified_at_spec.rb └── spec_helper.rb /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: gjtorikian 4 | # patreon: gjtorikian 5 | # open_collective: garen-torikian 6 | #ko_fi: # Replace with a single Ko-fi username 7 | #tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | #community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | #liberapay: # Replace with a single Liberapay username 10 | # issuehunt: gjtorikian 11 | #otechie: # Replace with a single Otechie username 12 | #custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 13 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "github-actions" 4 | directory: "/" 5 | schedule: 6 | interval: weekly 7 | day: monday 8 | time: "09:00" 9 | timezone: "Etc/UTC" 10 | groups: 11 | github-actions: 12 | patterns: 13 | - "*" 14 | open-pull-requests-limit: 10 15 | 16 | - package-ecosystem: bundler 17 | directory: "/" 18 | schedule: 19 | interval: weekly 20 | day: monday 21 | time: "09:00" 22 | timezone: "Etc/UTC" 23 | open-pull-requests-limit: 10 24 | groups: 25 | bundler-dependencies: 26 | patterns: 27 | - "*" 28 | -------------------------------------------------------------------------------- /.github/workflows/automerge.yml: -------------------------------------------------------------------------------- 1 | name: "Bot auto-{approve,merge}" 2 | 3 | on: 4 | workflow_dispatch: 5 | pull_request_target: 6 | 7 | permissions: 8 | pull-requests: write 9 | contents: write 10 | 11 | jobs: 12 | dependabot: 13 | uses: yettoapp/actions/.github/workflows/automerge_dependabot.yml@main 14 | secrets: inherit 15 | with: 16 | automerge: true 17 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: Linting 2 | 3 | on: 4 | pull_request: 5 | paths: 6 | - "**/*.rb" 7 | 8 | permissions: 9 | contents: read 10 | 11 | jobs: 12 | lint: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v4 16 | 17 | - name: Set up Ruby 18 | uses: yettoapp/actions/setup-languages@main 19 | with: 20 | ruby: true 21 | 22 | - name: Rubocop 23 | run: bundle exec rake rubocop 24 | -------------------------------------------------------------------------------- /.github/workflows/tag_and_release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | workflow_dispatch: 5 | push: 6 | branches: 7 | - main 8 | paths: 9 | - "lib/jekyll-last-modified-at/version.rb" 10 | pull_request_target: 11 | types: 12 | - closed 13 | 14 | jobs: 15 | ruby: 16 | uses: yettoapp/actions/.github/workflows/ruby_gem_release.yml@main 17 | secrets: 18 | rubygems_api_key: ${{ secrets.RUBYGEMS_API_BOT_KEY }} 19 | gh_token: ${{ secrets.GITHUB_TOKEN }} 20 | with: 21 | gem_name: jekyll-last-modified-at 22 | version_filepath: lib/jekyll-last-modified-at/version.rb 23 | prepare: ${{ github.event_name == 'push' }} 24 | release: ${{ github.event_name == 'workflow_dispatch' || ((github.event.pull_request.merged == true) && (contains(github.event.pull_request.labels.*.name, 'release'))) }} 25 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | pull_request: 5 | 6 | permissions: 7 | contents: read 8 | 9 | jobs: 10 | test: 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - if: "${{ contains(github.event.pull_request.title, '[skip test]') }}" 15 | name: Skip test 16 | shell: bash 17 | run: | 18 | echo "Skipping test workflow because commit message contains `[skip test]`" 19 | exit 0 20 | 21 | - uses: actions/checkout@v4 22 | 23 | - name: Set up Ruby 24 | uses: yettoapp/actions/setup-languages@main 25 | with: 26 | ruby: true 27 | 28 | - name: Run tests 29 | run: bundle exec rake test 30 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.gem 2 | _site/ 3 | Gemfile.lock 4 | spec/fixtures/_posts/1992-09-11-last-modified-at.md 5 | spec/fixtures/.jekyll-metadata 6 | spec/fixtures/.jekyll-cache 7 | spec/dev/out.txt 8 | spec/dev/err.txt 9 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | inherit_gem: 2 | rubocop-standard: 3 | - config/default.yml 4 | - config/minitest.yml 5 | 6 | inherit_mode: 7 | merge: 8 | - Exclude 9 | 10 | AllCops: 11 | Exclude: 12 | - test/progit/**/* 13 | - "pkg/**/*" 14 | - "ext/**/*" 15 | - "vendor/**/*" 16 | - "tmp/**/*" 17 | - "test/progit/**/*" 18 | -------------------------------------------------------------------------------- /.ruby-version: -------------------------------------------------------------------------------- 1 | 3.2.1 2 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "[ruby]": { 3 | "editor.defaultFormatter": "Shopify.ruby-lsp" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # [v1.3.2] - 04-06-2024 2 | ## What's Changed 3 | * Modernize project by @gjtorikian in https://github.com/gjtorikian/jekyll-last-modified-at/pull/99 4 | * lint by @gjtorikian in https://github.com/gjtorikian/jekyll-last-modified-at/pull/100 5 | * :gem: 1.3.1 by @gjtorikian in https://github.com/gjtorikian/jekyll-last-modified-at/pull/101 6 | * :gem: 1.3.2 by @gjtorikian in https://github.com/gjtorikian/jekyll-last-modified-at/pull/102 7 | 8 | 9 | **Full Changelog**: https://github.com/gjtorikian/jekyll-last-modified-at/compare/v1.3.0...v1.3.2 10 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source "https://rubygems.org" 4 | 5 | gemspec 6 | 7 | gem "jekyll", ENV["JEKYLL_VERSION"] if ENV["JEKYLL_VERSION"] 8 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2024 Garen J. Torikian 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Last Modified At Plugin 2 | 3 | A liquid tag for Jekyll to indicate the last time a file was modified. 4 | 5 | This plugin determines a page's last modified date by checking the last Git commit date of source files. In the event Git is not available, the file's `mtime` is used. 6 | 7 | ## Setting up 8 | 9 | Open your Gemfile in your Jekyll root folder and add the following: 10 | 11 | ``` ruby 12 | group :jekyll_plugins do 13 | gem "jekyll-last-modified-at" 14 | end 15 | ``` 16 | 17 | Add the following to your site's `_config.yml` file 18 | 19 | ```yml 20 | plugins: 21 | - jekyll-last-modified-at 22 | 23 | # Optional. The default date format, used if none is specified in the tag. 24 | last-modified-at: 25 | date-format: '%d-%b-%y' 26 | ``` 27 | 28 | ## Usage 29 | 30 | There are a few ways to use this gem. 31 | 32 | You can place the following tag somewhere within your layout: 33 | 34 | ``` liquid 35 | {% last_modified_at %} 36 | ``` 37 | 38 | By default, this creates a time format matching `"%d-%b-%y"` (like "04-Jan-14"). 39 | 40 | You can also choose to pass along your own time format. For example: 41 | 42 | ```liquid 43 | {% last_modified_at %Y:%B:%A:%d:%S:%R %} 44 | ``` 45 | That produces "2014:January:Saturday:04." 46 | 47 | You can also call the method directly on a Jekyll "object," like so: 48 | 49 | ``` liquid 50 | {{ page.last_modified_at }} 51 | ``` 52 | 53 | To format such a time, you'll need to rely on Liquid's `date` filter: 54 | 55 | ``` liquid 56 | {{ page.last_modified_at | date: '%Y:%B:%A:%d:%S:%R' }} 57 | ``` 58 | 59 | (It's generally [more performant to use the `page.last_modified_at` version](https://github.com/gjtorikian/jekyll-last-modified-at/issues/24#issuecomment-55431108) of this plugin.) 60 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "bundler" 4 | Bundler::GemHelper.install_tasks 5 | 6 | require "rspec/core/rake_task" 7 | 8 | RSpec::Core::RakeTask.new(:spec) 9 | 10 | desc "Test the project" 11 | task :test do 12 | Rake::Task["spec"].invoke 13 | end 14 | 15 | require "rubocop/rake_task" 16 | 17 | RuboCop::RakeTask.new(:rubocop) 18 | 19 | require "bundler/gem_tasks" 20 | require "rubygems/package_task" 21 | GEMSPEC = Bundler.load_gemspec("jekyll-last-modified-at.gemspec") 22 | gem_path = Gem::PackageTask.new(GEMSPEC).define 23 | desc "Package the ruby gem" 24 | task "package" => [gem_path] 25 | -------------------------------------------------------------------------------- /jekyll-last-modified-at.gemspec: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require File.expand_path("lib/jekyll-last-modified-at/version.rb", __dir__) 4 | Gem::Specification.new do |s| 5 | s.name = "jekyll-last-modified-at" 6 | s.version = Jekyll::LastModifiedAt::VERSION 7 | s.summary = "A liquid tag for Jekyll to indicate the last time a file was modified." 8 | s.authors = "Garen J. Torikian" 9 | s.homepage = "https://github.com/gjtorikian/jekyll-last-modified-at" 10 | s.license = "MIT" 11 | s.files = Dir["lib/**/*.rb"] 12 | 13 | s.add_dependency("jekyll", ">= 3.7", " < 5.0") 14 | 15 | s.add_development_dependency("rake") 16 | s.add_development_dependency("rspec", "~> 3.4") 17 | s.add_development_dependency("rubocop") 18 | s.add_development_dependency("rubocop-standard") 19 | s.add_development_dependency("spork") 20 | end 21 | -------------------------------------------------------------------------------- /lib/jekyll-last-modified-at.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Jekyll 4 | module LastModifiedAt 5 | require "jekyll-last-modified-at/tag" 6 | require "jekyll-last-modified-at/hook" 7 | 8 | autoload :VERSION, "jekyll-last-modified-at/version" 9 | autoload :Executor, "jekyll-last-modified-at/executor" 10 | autoload :Determinator, "jekyll-last-modified-at/determinator" 11 | autoload :Git, "jekyll-last-modified-at/git" 12 | 13 | PATH_CACHE = {} 14 | REPO_CACHE = {} 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /lib/jekyll-last-modified-at/determinator.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Jekyll 4 | module LastModifiedAt 5 | class Determinator 6 | attr_reader :site_source, :page_path 7 | attr_accessor :format 8 | 9 | def initialize(site_source, page_path, format = nil) 10 | @site_source = site_source 11 | @page_path = page_path 12 | @format = format || "%d-%b-%y" 13 | end 14 | 15 | def git 16 | return REPO_CACHE[site_source] unless REPO_CACHE[site_source].nil? 17 | 18 | REPO_CACHE[site_source] = Git.new(site_source) 19 | REPO_CACHE[site_source] 20 | end 21 | 22 | def formatted_last_modified_date 23 | return PATH_CACHE[page_path] unless PATH_CACHE[page_path].nil? 24 | 25 | last_modified = last_modified_at_time.strftime(@format) 26 | PATH_CACHE[page_path] = last_modified 27 | last_modified 28 | end 29 | 30 | def last_modified_at_time 31 | raise Errno::ENOENT, "#{absolute_path_to_article} does not exist!" unless File.exist?(absolute_path_to_article) 32 | 33 | Time.at(last_modified_at_unix.to_i) 34 | end 35 | 36 | def last_modified_at_unix 37 | if git.git_repo? 38 | last_commit_date = Executor.sh( 39 | "git", 40 | "--git-dir", 41 | git.top_level_directory, 42 | "log", 43 | "-n", 44 | "1", 45 | '--format="%ct"', 46 | "--", 47 | relative_path_from_git_dir, 48 | )[/\d+/] 49 | # last_commit_date can be nil iff the file was not committed. 50 | last_commit_date.nil? || last_commit_date.empty? ? mtime(absolute_path_to_article) : last_commit_date 51 | else 52 | mtime(absolute_path_to_article) 53 | end 54 | end 55 | 56 | def to_s 57 | @to_s ||= formatted_last_modified_date 58 | end 59 | 60 | def to_liquid 61 | @to_liquid ||= last_modified_at_time 62 | end 63 | 64 | private 65 | 66 | def absolute_path_to_article 67 | @absolute_path_to_article ||= Jekyll.sanitized_path(site_source, @page_path) 68 | end 69 | 70 | def relative_path_from_git_dir 71 | return unless git.git_repo? 72 | 73 | @relative_path_from_git_dir ||= Pathname.new(absolute_path_to_article) 74 | .relative_path_from( 75 | Pathname.new(File.dirname(git.top_level_directory)), 76 | ).to_s 77 | end 78 | 79 | def mtime(file) 80 | File.mtime(file).to_i.to_s 81 | end 82 | end 83 | end 84 | end 85 | -------------------------------------------------------------------------------- /lib/jekyll-last-modified-at/executor.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "open3" 4 | 5 | module Jekyll 6 | module LastModifiedAt 7 | module Executor 8 | class << self 9 | def sh(*args) 10 | stdout_str, stderr_str, status = Open3.capture3(*args) 11 | "#{stdout_str} #{stderr_str}".strip if status.success? 12 | end 13 | end 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /lib/jekyll-last-modified-at/git.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Jekyll 4 | module LastModifiedAt 5 | class Git 6 | attr_reader :site_source 7 | 8 | def initialize(site_source) 9 | @site_source = site_source 10 | @is_git_repo = nil 11 | end 12 | 13 | def top_level_directory 14 | return unless git_repo? 15 | 16 | @top_level_directory ||= begin 17 | Dir.chdir(@site_source) do 18 | @top_level_directory = File.join(Executor.sh("git", "rev-parse", "--show-toplevel"), ".git") 19 | end 20 | rescue StandardError 21 | "" 22 | end 23 | end 24 | 25 | def git_repo? 26 | return @is_git_repo unless @is_git_repo.nil? 27 | 28 | @is_git_repo = begin 29 | Dir.chdir(@site_source) do 30 | Executor.sh("git", "rev-parse", "--is-inside-work-tree").eql?("true") 31 | end 32 | rescue StandardError 33 | false 34 | end 35 | end 36 | end 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /lib/jekyll-last-modified-at/hook.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Jekyll 4 | module LastModifiedAt 5 | module Hook 6 | class << self 7 | def add_determinator_proc 8 | proc { |item| 9 | format = item.site.config.dig("last-modified-at", "date-format") 10 | item.data["last_modified_at"] = Determinator.new( 11 | item.site.source, 12 | item.path, 13 | format, 14 | ) 15 | } 16 | end 17 | end 18 | 19 | Jekyll::Hooks.register(:posts, :post_init, &Hook.add_determinator_proc) 20 | Jekyll::Hooks.register(:pages, :post_init, &Hook.add_determinator_proc) 21 | Jekyll::Hooks.register(:documents, :post_init, &Hook.add_determinator_proc) 22 | end 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /lib/jekyll-last-modified-at/tag.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Jekyll 4 | module LastModifiedAt 5 | class Tag < Liquid::Tag 6 | def initialize(tag_name, format, tokens) 7 | super 8 | @format = format.empty? ? nil : format.strip 9 | end 10 | 11 | def render(context) 12 | site = context.registers[:site] 13 | format = @format || site.config.dig("last-modified-at", "date-format") 14 | article_file = context.environments.first["page"]["path"] 15 | Determinator.new(site.source, article_file, format) 16 | .formatted_last_modified_date 17 | end 18 | end 19 | end 20 | end 21 | 22 | Liquid::Template.register_tag("last_modified_at", Jekyll::LastModifiedAt::Tag) 23 | -------------------------------------------------------------------------------- /lib/jekyll-last-modified-at/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Jekyll 4 | module LastModifiedAt 5 | VERSION = "1.3.2" 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /script/bootstrap: -------------------------------------------------------------------------------- 1 | #! /bin/bash 2 | 3 | bundle install 4 | -------------------------------------------------------------------------------- /script/cibuild: -------------------------------------------------------------------------------- 1 | #! /bin/bash 2 | 3 | script/bootstrap > /dev/null 2>&1 4 | bundle exec rake spec 5 | -------------------------------------------------------------------------------- /spec/dev/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gjtorikian/jekyll-last-modified-at/3052cbe5fdf550dd1e85ed8eb7ec7a5595cd9f1d/spec/dev/.gitkeep -------------------------------------------------------------------------------- /spec/fixtures/_config.yml: -------------------------------------------------------------------------------- 1 | name: Your New Jekyll Site 2 | timezone: UTC 3 | -------------------------------------------------------------------------------- /spec/fixtures/_layouts/last_modified_at.html: -------------------------------------------------------------------------------- 1 | 2 |
3 |