前言

昨天修改了文章後,發現更新時間沒有跟著改變,查了一下似乎只能自行更改 front-matter (markdown 最上面那一區)裡的 updated

但是每次改一篇文章都要手動修改感覺有點蠢,於是我繼續尋找有沒有辦法自動化,最後找到了使用 JavaScript 的方法。以下程式碼修改自原文章,順便做個筆記。

為現有文章加上 updated

若文章 front-matter 還沒有 updated,需要手動加入,可以直接複製 date: xxxx-xx-xx xx:xx:xx 這行,然後把 date 改成 updated 就好(之後執行程式會自動修正)。

---
title: 在 Hexo 部署時自動更新文章的編輯時間
date: 2023-01-18 15:09:05
updated: 2023-01-20 21:33:18
categories:
  - 架站筆記
tags:
  - Hexo
  - JavaScript
---

修改模板

我們當然不會想要每次都手動加上 updated,因此需要修改模版的 front-matter:

post.md
--- title: {{ title }} date: {{ date }} updated: {{ date }} categories: - tags: - ---

這樣以後生成新文章就會自動加上 updated 了。

自動修改更新日期

在根目錄建立 UpdateFileTime.js (若放在 source/ 裡,deploy 時會生成在網站裡):

UpdateFileTime.js
#!/usr/bin/env node // 自動更新文章的修改時間 console.log('開始執行'); var fs = require("fs"); // 用於讀寫文件 var RegExp = /(updated:\s*)((\d{2}(([02468][048])|([13579][26]))[\-\/\s]?((((0?[13578])|(1[02]))[\-\/\s]?((0?[1-9])|([1-2][0-9])|(3[01])))|(((0?[469])|(11))[\-\/\s]?((0?[1-9])|([1-2][0-9])|(30)))|(0?2[\-\/\s]?((0?[1-9])|([1-2][0-9])))))|(\d{2}(([02468][1235679])|([13579][01345789]))[\-\/\s]?((((0?[13578])|(1[02]))[\-\/\s]?((0?[1-9])|([1-2][0-9])|(3[01])))|(((0?[469])|(11))[\-\/\s]?((0?[1-9])|([1-2][0-9])|(30)))|(0?2[\-\/\s]?((0?[1-9])|(1[0-9])|(2[0-8]))))))(\s((([0-1][0-9])|(2?[0-3]))\:([0-5]?[0-9])((\s)|(\:([0-5]?[0-9])))))/; let toppath = "./source/_posts/"; function fn(path) { fs.readdir(path, (err, files) => { if (err) return console.log(err); files.forEach(function (item) { fs.stat(path + item, (err, data) => { if (err) return console.log(err); if (data.isFile()) { if (item.indexOf(".md") > -1) { writeFileTime(path + item, fs); } } else { fn(path + item + '/'); } }) }) }) } fn(toppath); function writeFileTime(file, fs) { fs.readFile(file, 'utf8', function (err, data) { // 讀取文件內容 if (err) return console.log("讀取檔案內容錯誤:", err); if (RegExp.test(data)) { // 尋找 updated 字串 fs.stat(file, function (err, stats) { // 讀取文件建立時間等資訊 if (err) return console.log("讀取檔案資料錯誤:", err); var updateds = data.match(RegExp); var updated = updateds[0].replace("updated: ", "").replace(/-/g, "/"); // 時間格式化為 xxxx/xx/xx xx:xx:xx if (new Date(stats.mtime).getTime() - new Date(Date.parse(updated)) > 1000 * 60 * 10) { // 只要修改時間和文章內 updated 時間差大於 10 分鍾就觸發更新 var result = data.replace(RegExp, "updated: " + getFormatDate(stats.mtime)); // 替換更新時間 console.log(result); fs.writeFile(file, result, 'utf8', function(err) { // 寫入新的檔案內容 if (err) return console.log(err); fs.utimes(file, new Date(stats.atime), new Date(stats.mtime), function (err) { // 還原訪問時間和修改時間 if (err) return console.log("修改時間失敗:", err); console.log(file, "成功更新時間"); }); }); } }); } }); } /* timeStr:時間,格式可為:"September 16,2016 14:15:05、 "September 16,2016"、"2016/09/16 14:15:05"、"2016/09/16"、 '2014-04-23T18:55:49'和毫秒 dateSeparator:年、月、日之間的分隔符,預設為"-", timeSeparator:時、分、秒之間的分隔符,預設為":" */ function getFormatDate(timeStr, dateSeparator, timeSeparator) { dateSeparator = dateSeparator ? dateSeparator : "-"; timeSeparator = timeSeparator ? timeSeparator : ":"; var date = new Date(timeStr), year = date.getFullYear(), // 四位數 month = date.getMonth(), // 0-11 day = date.getDate(), // 1-31 hour = date.getHours(), // 0-23 minute = date.getMinutes(), // 0-59 seconds = date.getSeconds(), // 0-59 Y = year + dateSeparator, M = ((month + 1) > 9 ? (month + 1) : ('0' + (month + 1))) + dateSeparator, D = (day > 9 ? day : ('0' + day)) + ' ', h = (hour > 9 ? hour : ('0' + hour)) + timeSeparator, m = (minute > 9 ? minute : ('0' + minute)) + timeSeparator, s = (seconds > 9 ? seconds : ('0' + seconds)), formatDate = Y + M + D + h + m + s; return formatDate; }

UpdateFileTime.js 解析

在這裡做個筆記,避免之後忘了這段程式在做什麼。

  • Line 5: fs (file system) module 用於讀寫文件。

  • Line 7: updated 與時間格式的正則表達式 (regular expression),型別是 RegExp

    • 若後面加上 g (global) 變成 /.../g,會搜尋所有符合的結果,否則只會搜尋第一個符合的,可以避免文章內的 updated 也被替換。
  • Line 9 ~ 26: 只尋找 source/_post/ 內的檔案,只選取 .md 檔,其他檔案跳過,若是目錄則繼續遞迴。選中的檔案進行時間更新。

    • Line 11: fs.readdir(path[, options], callback) 讀取資料夾內所有檔案,callback 是參數為 err, data 的 function。
  • Line 30: 需使用 UTF-8 打開,否則可能會編碼錯誤。

  • Line 32 & 35: RegExp.test(str) 可以檢查 str 內是否有符合 RegExp 的部分。str.match(RegExp) 則會回傳一個包含所有符合結果的陣列。

  • Line 33: fs.stat(path[, options], callback) 會回傳一個 fs.Stats 物件,結構如下:

    Stats {
      dev: 16777220,
      mode: 33188,
      nlink: 1,
      uid: 501,
      gid: 20,
      rdev: 0,
      blksize: 4096,
      ino: 14214074,
      size: 8,
      blocks: 8,
      atimeMs: 1561174616618.8555,
      mtimeMs: 1561174614584,
      ctimeMs: 1561174614583.8145,
      birthtimeMs: 1561174007710.7478,
      atime: 2019-06-22T03:36:56.619Z,
      mtime: 2019-06-22T03:36:54.584Z,
      ctime: 2019-06-22T03:36:54.584Z,
      birthtime: 2019-06-22T03:26:47.711Z
    }
  • Line 40: 由於執行這個程式會更動到檔案,因此使用 fs.utimes() 將存取與編輯時間還原成執行前的狀態。

我在寫這段的時候遇到一個笨問題,不管怎麼執行,文章的 updated 都沒有更新,一開始以為是出 bug,上網找了各種方法都沒用。
後來發現 VSCode 不會即時同步檔案(我一直以為會),把檔案關掉重開就是正確的結果了。

執行檔案

在根目錄執行一次這個檔案並查看效果:

node .\source\UpdateFileTime.js

在遠端 pull 更新正確時間

使用這段的腳本前提是原始檔案 (至少包含 source/_post/)已經被 git 追蹤,否則不會有效果。

若我們在其他電腦將原始檔案 pull 下來,檔案的最後編輯時間會變成 pull 的時間,會導致執行 UpdateFileTime.js 時全部變成當天的日期,因此在 pull 之後可以執行下面的腳本,將最後編輯時間復原為最後一次 commit 的時間(若檔案沒有被 git 追蹤則不變)。

restoreTime.sh
git ls-files -z | while read path; do touch -d "$(git log -1 --format="@%ct" "$path")" "$path"; done;

Windows 預設的換行格式是 CRLF (\r\n),請確保將此檔案的換行格式轉為 LF (\n),否則執行時會出現錯誤。

解析:

  • git ls-files: 顯示當前目錄中所有被 git 追蹤的檔案:

    $ git ls-files
    aaa.md
    bbb.md
    ccc.md
  • |: pipe,將左邊的輸出作為右邊的輸入。

    由於 pipe 裡的每個指令都會在獨立的 subshell 執行,因此在 pipe 裡產生的變數不會影響到外部。

  • read: 讀入資料,分割並分配給後面的變數。

    $ read var1 var2 <<< "Hello world!"
    $ echo $var1; echo var2
    Hello
    world!
  • while read path: 重複「讀取直到換行後傳給變數 path」到輸入結束為止。

    • dodone 分別為迴圈的開頭與結束。
  • touch: 用於更改檔案或目錄的時間戳記,或是建立空檔案。

    • -d: 設定日期與時間,其中一種格式為,使用 Unix timestamp 前面要加上 @
  • git log: 顯示 commit 紀錄。

    • -<n>: 顯示最後 n 筆 commit。
    $ git log -1
    commit 8cf50d13ccb7983e8c406573bf834fa9394418aa (HEAD -> master)
    Author: JHTNT <xxxxx@gmail.com>
    Date:   Wed Oct 12 16:25:10 2022 +0800
    
        Draft: temp
    • --format: 可以自訂輸出的格式,例如:
      • %H, %h: 顯示完整 / 簡短的 commit hash 值。
      • %an: 作者的名字。
      • %cn: 提交者的名字。
      • %ct: 以 UNIX timestamp 形式顯示 commit 時間。
      • %cs: 以 YYYY-MM-DD 形式顯示 commit 時間。

一鍵部署

我們可以將 UpdateFileTime.js 與 Hexo 部署的指令寫成一個腳本,方便之後直接一鍵部署,並同時將原始檔案 push 到 github,關於儲存原始檔案可以查看這篇

deploy.bat
call node .\UpdateFileTime.js call git add . call git commit -m %1 call git push call hexo clean call hexo generate call hexo deploy

之後要部署只需要在根目錄執行 .\deploy.bat "[commit message]" 指令即可(記得加上引號),例如:

.\deploy.bat "add: commit test"

雖然最後編輯時間會與 commit 時間有所差別,但目前沒找到解決辦法,且我覺得不會造成太大的問題,因此就暫時保持這樣。

Reference