Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions docs/schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,16 @@
]
},

"timezone": {
"type": "string",
"markdownDescription": "IANA timezone name used to render dates. Git timestamps are resolved into calendar days in this timezone, and Front Matter values without a timezone are interpreted as being in it. Values that carry an explicit timezone are left untouched. Defaults to the build machine's local timezone.",
"examples": [
"Asia/Shanghai",
"UTC",
"America/New_York"
]
},

"date_format": {
"type": "string",
"default": "%Y-%m-%d",
Expand Down
88 changes: 88 additions & 0 deletions mkdocs_document_dates/cache_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,90 @@ def find_mkdocs_projects() -> dict[Path, Path]:

return projects

def get_renamed_files(docs_dir: Path) -> dict:
"""获取本次提交中被重命名/移动的 markdown 文件,返回 {旧路径: 新路径}

路径均相对 docs_dir,与 JSONL 缓存的 key 格式一致。
在 pre-commit 阶段对比 HEAD 与暂存区,依靠 git 的 -M 相似度检测识别移动,
因此 `git mv` 和「手动 mv + git add」两种方式都能覆盖。

这里刻意以仓库根为视角(--no-relative)再自行过滤,只保留两端都在 docs_dir
内的重命名。若改用 --relative,git 只能看见 docs_dir 内部的增删,会把
「移出去的文件」和「移进来的相似文件」误配成一次重命名,导致创建日期张冠李戴。
"""
renames = {}
try:
git_root = Path(subprocess.check_output(
["git", "rev-parse", "--show-toplevel"],
cwd=docs_dir, env=_clean_git_env(), encoding="utf-8"
).strip()).resolve()
rel_docs = docs_dir.resolve().relative_to(git_root)
# docs_dir 就是仓库根时前缀为空,否则形如 "docs/"
prefix = "" if rel_docs == Path(".") else rel_docs.as_posix() + "/"

# --no-relative 抵消用户可能配置的 diff.relative=true
cmd = [
"git", "-c", "core.quotepath=false",
"diff", "--cached", "-M", "--name-status", "-z", "--no-relative",
]
result = subprocess.run(cmd, cwd=docs_dir, env=_clean_git_env(),
capture_output=True, encoding="utf-8")
if result.returncode != 0 or not result.stdout:
return renames

# -z 输出格式: 重命名为 "R<score>\0旧路径\0新路径",其余为 "<status>\0路径"
fields = result.stdout.split("\0")
i = 0
while i < len(fields):
status = fields[i]
if not status:
i += 1
continue
# R(重命名) 和 C(复制) 均为三字段,但只有 R 需要迁移
if status[0] in ("R", "C"):
if i + 2 < len(fields):
old_path, new_path = fields[i + 1], fields[i + 2]
# 只保留两端都在 docs_dir 内的 markdown 重命名,
# 跨 docs_dir 边界的移动没有可继承的创建日期
if (status[0] == "R"
and old_path.endswith(".md") and new_path.endswith(".md")
and old_path.startswith(prefix) and new_path.startswith(prefix)):
renames[old_path[len(prefix):]] = new_path[len(prefix):]
i += 3
else:
i += 2
except Exception as e:
logger.warning(f"Failed to detect renamed files in {docs_dir}: {e}")
return renames

def migrate_renamed_entries(dates_cache: dict, docs_dir: Path) -> bool:
"""把被重命名文件的创建日期从旧路径迁移到新路径

不这样做的话,新路径不在缓存中,会被当作新文件重新取创建时间
(Linux 上即文件的 mtime,也就是重命名的那一刻),原始创建日期就丢了。
"""
renames = get_renamed_files(docs_dir)
if not renames:
return False

# 分两阶段:先全部摘出,再统一落位。
# 这样 a→b 与 b→a 这类互换也能正确处理(单阶段时会因目标已存在而互相阻塞)
pending = {}
for old_path, new_path in renames.items():
if old_path in dates_cache:
pending[new_path] = (old_path, dates_cache.pop(old_path))

migrated = False
for new_path, (old_path, info) in pending.items():
# 目标已被别的条目占用时不覆盖(重命名恰好盖掉一个已存在的文件)
if new_path in dates_cache:
logger.info(f"Skipped migration, target already exists: {old_path} -> {new_path}")
continue
dates_cache[new_path] = info
migrated = True
logger.info(f"Migrated created date: {old_path} -> {new_path}")
return migrated

def setup_gitattributes(docs_dir: Path):
try:
gitattributes_path = docs_dir / ".gitattributes"
Expand Down Expand Up @@ -187,6 +271,10 @@ def update_cache():
jsonl_cache_file = docs_dir / ".dates_cache.jsonl"
jsonl_dates_cache = read_jsonl_cache(jsonl_cache_file)

# 迁移重命名/移动文件的创建日期(须在下面的循环之前,否则会被当作新文件处理)
if jsonl_dates_cache:
project_updated |= migrate_renamed_entries(jsonl_dates_cache, docs_dir)

# 根据 git已跟踪的文件来更新
for rel_path in tracked_files:
try:
Expand Down
54 changes: 46 additions & 8 deletions mkdocs_document_dates/plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ class DocumentDatesPlugin(BasePlugin):
config_scheme = (
('type', config_options.Type(str, default='date')),
('locale', config_options.Type(str, default='')),
('timezone', config_options.Type(str, default='')),
('date_format', config_options.Type(str, default='%Y-%m-%d')),
('time_format', config_options.Type(str, default='%H:%M:%S')),
('position', config_options.Type(str, default='top')),
Expand All @@ -51,9 +52,34 @@ def __init__(self):
self.recent_docs_html = None
self.recent_enable = False
self._exclude_patterns = []
self.tz = None

def _resolve_timezone(self, name: str):
"""解析配置的时区名,留空则回退到构建机器的本地时区(保持旧行为)

这个时区同时承担两个角色:
1. 把 git 的时间戳(绝对时刻)落地成"哪一天"
2. 解释 Front Matter 中不带时区的时间(视作作者所在时区)
显式写了时区的 Front Matter 值不受影响,以它自己的为准。
"""
local_tz = datetime.now().astimezone().tzinfo
if not name:
return local_tz
try:
# zoneinfo 返回的 tzinfo 可安全用于 replace(),优先使用
from zoneinfo import ZoneInfo
return ZoneInfo(name)
except Exception:
try:
from babel.dates import get_timezone
return get_timezone(name)
except Exception as e:
logger.warning(f"Unknown timezone '{name}', falling back to local timezone: {e}")
return local_tz

def on_config(self, config):
docs_dir_path = Path(config.docs_dir)
self.tz = self._resolve_timezone(self.config['timezone'])

# 加载 author 配置
authors_file = None
Expand Down Expand Up @@ -152,6 +178,17 @@ def on_config(self, config):
@event_priority(50)
def on_files(self, files, config):
self.data_cached = load_dates_and_authors(Path(config.docs_dir), files)

# 缓存里是 UTC,统一转成配置的时区。
# data_cached 是对外的日期 API(例如 MaterialX 的 blog 插件会直接读它来定
# 文章日期),转换只改变呈现方式、不改变绝对时刻,排序与比较都不受影响。
if self.tz:
for info in self.data_cached.values():
for key in ('created', 'updated'):
value = info.get(key)
if isinstance(value, datetime):
info[key] = value.astimezone(self.tz)

return files

@event_priority(50)
Expand Down Expand Up @@ -179,11 +216,11 @@ def on_page_markdown(self, markdown, page: Page, config, files):
if not authors:
authors = self._load_author_cached(rel_path, page, config)

# 注入数据到模板 (utc datetime -> local datetime)
# 注入数据到模板 (utc datetime -> configured timezone)
page.meta["document_dates"] = {
"dates": {
"created": created.astimezone().isoformat() if created else None,
"updated": updated.astimezone().isoformat() if updated else None,
"created": created.astimezone(self.tz).isoformat() if created else None,
"updated": updated.astimezone(self.tz).isoformat() if updated else None,
},
"authors": authors
}
Expand Down Expand Up @@ -227,7 +264,7 @@ def on_env(self, env, config, files):

# 获取最近更新的文档数据
recent_exclude_patterns = compile_exclude_patterns(exclude_list)
recently_updated_docs = get_recently_updated_files(self.data_cached, files, recent_exclude_patterns, limit, self.recent_enable, prefix, wpm, wpm_cjk)
recently_updated_docs = get_recently_updated_files(self.data_cached, files, recent_exclude_patterns, limit, self.recent_enable, prefix, wpm, wpm_cjk, self.tz)

# 将数据注入到 config['extra'] 中供全局访问
if not config.get('extra', {}).get("recently_updated_docs", {}):
Expand Down Expand Up @@ -306,10 +343,10 @@ def _load_meta_date(self, meta, field_names):
# 移除首尾可能存在的单双引号
date_str = str(meta[field]).strip("'\"")
dt = datetime.fromisoformat(date_str)
# 如果没时区,则当成本地时间,再转 UTC
# 没写时区就按配置的时区解释(即作者所在时区),再转 UTC;
# 显式写了时区的以它自己的为准
if dt.tzinfo is None:
local_tz = datetime.now().astimezone().tzinfo
dt = dt.replace(tzinfo=local_tz)
dt = dt.replace(tzinfo=self.tz or datetime.now().astimezone().tzinfo)
return dt.astimezone(timezone.utc)
Comment on lines +346 to 350
# return datetime.fromisoformat(date_str).astimezone()
except Exception:
Expand Down Expand Up @@ -411,6 +448,7 @@ def _formatting_date(self, date: datetime):
return format_datetime(
date,
format=fmt,
tzinfo=self.tz,
locale=locale
)

Expand All @@ -436,7 +474,7 @@ def build_time_icon(time_obj: datetime, icon: str):
return (
f"<span class='dd-item' data-tippy-content data-tippy-raw='{formatted}'>"
f"<span class='material-icons' data-icon='{icon}'></span>"
f"<time datetime='{time_obj.astimezone().isoformat()}'>{formatted}</time>"
f"<time datetime='{time_obj.astimezone(self.tz).isoformat()}'>{formatted}</time>"
f"</span>"
)

Expand Down
6 changes: 3 additions & 3 deletions mkdocs_document_dates/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -233,7 +233,7 @@ def load_git_last_updated_dates(docs_dir_path: Path):
return doc_mtime_map

# 建议在 on_page_markdown 之后的全局事件中调用,因为需要读取 page.meta 中的信息
def get_recently_updated_files(existing_dates: dict, files: Files, exclude_list: list, limit: int = 10, recent_enable: bool = False, prefix: str = "", wpm: int = DEFAULT_WPM, wpm_cjk: int = DEFAULT_WPM_CJK):
def get_recently_updated_files(existing_dates: dict, files: Files, exclude_list: list, limit: int = 10, recent_enable: bool = False, prefix: str = "", wpm: int = DEFAULT_WPM, wpm_cjk: int = DEFAULT_WPM_CJK, tz=None):
recently_updated_results = []
if recent_enable:
files_meta = []
Expand Down Expand Up @@ -288,8 +288,8 @@ def get_recently_updated_files(existing_dates: dict, files: Files, exclude_list:
recently_updated_results = heapq.nlargest(limit, files_meta, key=itemgetter("updated_ts"))

for doc in recently_updated_results:
# timestamp -> utc datetime -> local datetime
dt = datetime.fromtimestamp(doc["updated_ts"], tz=timezone.utc).astimezone()
# timestamp -> utc datetime -> configured timezone (tz=None 时为本地时区)
dt = datetime.fromtimestamp(doc["updated_ts"], tz=timezone.utc).astimezone(tz)
doc["updated_dt"] = dt.isoformat()
doc["updated"] = dt.date().isoformat()

Expand Down