@xingwangzhe/stalux 1.41.1 → 1.41.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/components/stalux/archives/archivesList.astro +10 -7
- package/src/components/stalux/layout/head.astro +13 -1
- package/src/components/stalux/words/wordList.astro +2 -2
- package/src/pages/api/posts.json.ts +7 -2
- package/src/utils/config-utils.ts +2 -0
- package/src/utils/dayjs.ts +70 -3
- package/src/utils/feed.ts +5 -2
- package/src/utils/semantic-time.ts +2 -1
package/package.json
CHANGED
|
@@ -3,7 +3,7 @@ import "../../../styles/generated-archives.css";
|
|
|
3
3
|
import { type CollectionEntry, getCollection } from "astro:content";
|
|
4
4
|
|
|
5
5
|
import { getSiteData } from "@utils/config-utils";
|
|
6
|
-
import {
|
|
6
|
+
import { formatInTimeZone, parseDate, toTimestamp, zonedCalendarParts } from "@utils/dayjs";
|
|
7
7
|
import { createTranslator } from "@utils/i18n";
|
|
8
8
|
|
|
9
9
|
interface Props {
|
|
@@ -16,13 +16,15 @@ const posts = props.posts ?? (await getCollection("posts", ({ data }) => !data.d
|
|
|
16
16
|
const configEntries = await getCollection("config");
|
|
17
17
|
const siteConfig = getSiteData(configEntries);
|
|
18
18
|
const { t } = createTranslator(siteConfig.lang || "zh-CN");
|
|
19
|
+
// 归档按“站点时区的日历”分组:无时区的 date 串按站点时区解析,
|
|
20
|
+
// 分组也按站点时区取年月,构建机器时区(UTC CI)不再影响归档归属。
|
|
21
|
+
const timezone = siteConfig.timezone || "Asia/Shanghai";
|
|
19
22
|
const archivesByYearMonth = new Map<number, Map<number, CollectionEntry<"posts">[]>>();
|
|
20
23
|
|
|
21
24
|
posts.forEach((post) => {
|
|
22
25
|
if (!post.data.date) return;
|
|
23
|
-
const date = parseDate(post.data.date);
|
|
24
|
-
const year = date
|
|
25
|
-
const month = date.getUTCMonth() + 1;
|
|
26
|
+
const date = parseDate(post.data.date, timezone);
|
|
27
|
+
const { year, month } = zonedCalendarParts(date, timezone);
|
|
26
28
|
const months = archivesByYearMonth.get(year) ?? new Map();
|
|
27
29
|
const monthPosts = months.get(month) ?? [];
|
|
28
30
|
monthPosts.push(post);
|
|
@@ -32,7 +34,7 @@ posts.forEach((post) => {
|
|
|
32
34
|
|
|
33
35
|
// 每个 post 只解析一次时间,供排序比较器复用,避免比较器里反复 new Date()
|
|
34
36
|
const tsCache = new Map<CollectionEntry<"posts">, number>(
|
|
35
|
-
posts.map((p) => [p, toTimestamp(p.data.date || 0)]),
|
|
37
|
+
posts.map((p) => [p, toTimestamp(p.data.date || 0, timezone)]),
|
|
36
38
|
);
|
|
37
39
|
|
|
38
40
|
// 对每个月内的文章按 date 字段降序排序(用缓存时间戳做纯数字比较)
|
|
@@ -81,8 +83,9 @@ const archiveYears = [...archivesByYearMonth.entries()]
|
|
|
81
83
|
class="stalux-archive-post-date"
|
|
82
84
|
datetime={post.data.date}
|
|
83
85
|
>
|
|
84
|
-
{
|
|
85
|
-
parseDate(post.data.date),
|
|
86
|
+
{formatInTimeZone(
|
|
87
|
+
parseDate(post.data.date, timezone),
|
|
88
|
+
timezone,
|
|
86
89
|
"yyyy-MM-dd",
|
|
87
90
|
)}
|
|
88
91
|
</time>
|
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
getPromoteData,
|
|
17
17
|
getSiteData,
|
|
18
18
|
} from "@utils/config-utils";
|
|
19
|
+
import { toMachineDateTime } from "@utils/semantic-time";
|
|
19
20
|
import { buildSeoDocument, type SeoPageInput } from "@utils/seo-document";
|
|
20
21
|
import { SEO } from "astro-seo";
|
|
21
22
|
import packageJson from "../../../../package.json";
|
|
@@ -27,11 +28,22 @@ const headConfig = getHeadData(configEntries);
|
|
|
27
28
|
const promoteConfig = getPromoteData(configEntries);
|
|
28
29
|
const mediaLinksConfig = getMediaLinksData(configEntries);
|
|
29
30
|
|
|
31
|
+
// og:article:published_time 与 JSON-LD datePublished 必须带站点时区偏移。
|
|
32
|
+
// 裸的 frontmatter 串("2026-10-24 12:55:00")会被消费方按自己的时区解释,
|
|
33
|
+
// 统一在这里转换为带偏移的机器时间,与 RSS pubDate、页面 <time> 保持一致。
|
|
34
|
+
const seoTimezone = siteConfig.timezone || "Asia/Shanghai";
|
|
35
|
+
const rawPage = Astro.props as SeoPageInput;
|
|
30
36
|
const seo = buildSeoDocument({
|
|
31
37
|
site: siteConfig,
|
|
32
38
|
author: authorConfig,
|
|
33
39
|
mediaLinks: mediaLinksConfig?.items.map((item) => item.link) ?? [],
|
|
34
|
-
page:
|
|
40
|
+
page: {
|
|
41
|
+
...rawPage,
|
|
42
|
+
date: rawPage.date ? toMachineDateTime(rawPage.date, seoTimezone) : rawPage.date,
|
|
43
|
+
updated: rawPage.updated
|
|
44
|
+
? toMachineDateTime(rawPage.updated, seoTimezone)
|
|
45
|
+
: rawPage.updated,
|
|
46
|
+
},
|
|
35
47
|
pathname: Astro.url.pathname,
|
|
36
48
|
protocol: Astro.url.protocol,
|
|
37
49
|
exportMarkdown: Boolean(promoteConfig?.export_md),
|
|
@@ -13,8 +13,8 @@ interface Props {
|
|
|
13
13
|
const { entries, timezone } = Astro.props;
|
|
14
14
|
|
|
15
15
|
const sorted = [...entries].sort((a, b) => {
|
|
16
|
-
const ta = toTimestamp(a.data.date || a.id);
|
|
17
|
-
const tb = toTimestamp(b.data.date || b.id);
|
|
16
|
+
const ta = toTimestamp(a.data.date || a.id, timezone);
|
|
17
|
+
const tb = toTimestamp(b.data.date || b.id, timezone);
|
|
18
18
|
return tb - ta;
|
|
19
19
|
});
|
|
20
20
|
---
|
|
@@ -1,4 +1,7 @@
|
|
|
1
|
+
import { getCollection } from "astro:content";
|
|
2
|
+
import { getSiteData } from "@utils/config-utils";
|
|
1
3
|
import { getPostContentIndex } from "@utils/content-index";
|
|
4
|
+
import { toMachineDateTime } from "@utils/semantic-time";
|
|
2
5
|
import { sitePath } from "@utils/site-path";
|
|
3
6
|
import { getPostDescriptions } from "@utils/word-count-utils";
|
|
4
7
|
import type { APIRoute } from "astro";
|
|
@@ -15,11 +18,13 @@ export const GET: APIRoute = async ({ logger }) => {
|
|
|
15
18
|
const { posts } = await getPostContentIndex();
|
|
16
19
|
|
|
17
20
|
const descriptions = await getPostDescriptions(logger);
|
|
21
|
+
// 面向机器的端点必须输出带时区偏移的 ISO 时间;裸 frontmatter 串会被消费方按本地时区解释
|
|
22
|
+
const timezone = getSiteData(await getCollection("config")).timezone || "Asia/Shanghai";
|
|
18
23
|
const payload = posts.map((post) => ({
|
|
19
24
|
title: post.data.title,
|
|
20
25
|
abbrlink: String(post.data.abbrlink),
|
|
21
|
-
date: post.data.date
|
|
22
|
-
updated: post.data.updated
|
|
26
|
+
date: post.data.date ? toMachineDateTime(post.data.date, timezone) : undefined,
|
|
27
|
+
updated: post.data.updated ? toMachineDateTime(post.data.updated, timezone) : undefined,
|
|
23
28
|
tags: post.data.tags ?? [],
|
|
24
29
|
categories: post.data.categories ?? [],
|
|
25
30
|
desc: post.data.desc ?? "",
|
|
@@ -44,6 +44,8 @@ export function getSiteData(entries: CollectionEntry<"config">[]): SiteData {
|
|
|
44
44
|
return {
|
|
45
45
|
...site,
|
|
46
46
|
accentColor: site.accentColor ?? "#EAB308",
|
|
47
|
+
// 时间解析全部依赖站点时区(feed/semantic-time/archives),缺省值必须与 schema 默认一致
|
|
48
|
+
timezone: site.timezone ?? "Asia/Shanghai",
|
|
47
49
|
weather: { enabled: site.weather?.enabled ?? false },
|
|
48
50
|
};
|
|
49
51
|
}
|
package/src/utils/dayjs.ts
CHANGED
|
@@ -3,9 +3,18 @@
|
|
|
3
3
|
* 保留旧 dayjs 封装导出名,方便现有调用方继续使用。
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
/**
|
|
7
|
-
|
|
6
|
+
/**
|
|
7
|
+
* 解析日期字符串,返回 Date 对象。
|
|
8
|
+
* @param v 原始值(Date/时间戳/时间串)
|
|
9
|
+
* @param timeZone 站点 IANA 时区。传入时,不带时区的日期串按该时区挂钟时间解析,
|
|
10
|
+
* 保证任意构建机器(UTC 的 CI、东八区的本机)产出完全一致的时间戳。
|
|
11
|
+
*/
|
|
12
|
+
export const parseDate = (v: unknown, timeZone?: string): Date => {
|
|
8
13
|
if (v == null || v === 0 || v === "") return new Date(0);
|
|
14
|
+
if (typeof v === "string") {
|
|
15
|
+
const trimmed = v.trim();
|
|
16
|
+
if (timeZone && NAIVE_DATE_PATTERN.test(trimmed)) return parseDateInZone(trimmed, timeZone);
|
|
17
|
+
}
|
|
9
18
|
const d = new Date(v as string | number | Date);
|
|
10
19
|
return Number.isNaN(d.getTime()) ? new Date(0) : d;
|
|
11
20
|
};
|
|
@@ -14,7 +23,65 @@ export const parseDate = (v: unknown): Date => {
|
|
|
14
23
|
export const isValid = (d: Date): boolean => !Number.isNaN(d.getTime());
|
|
15
24
|
|
|
16
25
|
/** 获取时间戳(用于排序比较) */
|
|
17
|
-
export const toTimestamp = (v: unknown): number =>
|
|
26
|
+
export const toTimestamp = (v: unknown, timeZone?: string): number =>
|
|
27
|
+
parseDate(v, timeZone).getTime();
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* 不含时区信息的日期串(frontmatter 里的 "2026-10-24 12:55:00")。
|
|
31
|
+
* 这类串必须按站点时区解析,绝不能交给 new Date()——它会按
|
|
32
|
+
* **构建机器的本地时区** 解释,EdgeOne/CI 这类 UTC 构建机会让
|
|
33
|
+
* RSS pubDate、页面 datetime 全体平移 8 小时(2026-10-24 事故)。
|
|
34
|
+
*/
|
|
35
|
+
const NAIVE_DATE_PATTERN =
|
|
36
|
+
/^(\d{4})-(\d{2})-(\d{2})(?:[ T](\d{2}):(\d{2})(?::(\d{2})(?:\.(\d{1,3}))?)?)?$/;
|
|
37
|
+
|
|
38
|
+
/** 用共享的 formatter 计算目标时区在给定时刻的 UTC 偏移(毫秒,东八区为 +28800000) */
|
|
39
|
+
function zoneOffsetMs(date: Date, timeZone: string): number {
|
|
40
|
+
const parts = getTimezoneFormatter(timeZone).formatToParts(date);
|
|
41
|
+
const get = (type: Intl.DateTimeFormatPartTypes): number =>
|
|
42
|
+
Number(parts.find((part) => part.type === type)?.value ?? "0");
|
|
43
|
+
const asUtc = Date.UTC(
|
|
44
|
+
get("year"),
|
|
45
|
+
get("month") - 1,
|
|
46
|
+
get("day"),
|
|
47
|
+
get("hour"),
|
|
48
|
+
get("minute"),
|
|
49
|
+
get("second"),
|
|
50
|
+
);
|
|
51
|
+
return asUtc - date.getTime();
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* 把不含时区的日期串按指定 IANA 时区的挂钟时间解析为 Date。
|
|
56
|
+
* 做法是先按 UTC 解释,再用目标时区偏移折算;迭代两次以收敛 DST 边界
|
|
57
|
+
* (南半球夏令时附近的“不存在时间”取近似值,与主流库行为一致)。
|
|
58
|
+
*/
|
|
59
|
+
export function parseDateInZone(naive: string, timeZone: string): Date {
|
|
60
|
+
const match = naive.trim().match(NAIVE_DATE_PATTERN);
|
|
61
|
+
if (!match) return new Date(0);
|
|
62
|
+
const [year, month, day] = [Number(match[1]), Number(match[2]), Number(match[3])];
|
|
63
|
+
const hour = Number(match[4] ?? 0);
|
|
64
|
+
const minute = Number(match[5] ?? 0);
|
|
65
|
+
const second = Number(match[6] ?? 0);
|
|
66
|
+
const millisecond = Number((match[7] ?? "0").padEnd(3, "0"));
|
|
67
|
+
const wallClockAsUtc = Date.UTC(year, month - 1, day, hour, minute, second, millisecond);
|
|
68
|
+
let timestamp = wallClockAsUtc;
|
|
69
|
+
for (let iteration = 0; iteration < 2; iteration++) {
|
|
70
|
+
timestamp = wallClockAsUtc - zoneOffsetMs(new Date(timestamp), timeZone);
|
|
71
|
+
}
|
|
72
|
+
return new Date(timestamp);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** 读取目标时区下的日历字段(归档按月分组等场景需要“站点时区的日历”,而非 UTC 日历) */
|
|
76
|
+
export function zonedCalendarParts(
|
|
77
|
+
date: Date,
|
|
78
|
+
timeZone: string,
|
|
79
|
+
): { year: number; month: number; day: number } {
|
|
80
|
+
const parts = getTimezoneFormatter(timeZone).formatToParts(date);
|
|
81
|
+
const get = (type: Intl.DateTimeFormatPartTypes): number =>
|
|
82
|
+
Number(parts.find((part) => part.type === type)?.value ?? "0");
|
|
83
|
+
return { year: get("year"), month: get("month"), day: get("day") };
|
|
84
|
+
}
|
|
18
85
|
|
|
19
86
|
const pad2 = (n: number): string => String(n).padStart(2, "0");
|
|
20
87
|
|
package/src/utils/feed.ts
CHANGED
|
@@ -33,7 +33,10 @@ export async function buildFeedItems(
|
|
|
33
33
|
logger?: AstroRuntimeLogger,
|
|
34
34
|
): Promise<FeedItem[]> {
|
|
35
35
|
const timestamps = new Map(
|
|
36
|
-
posts.map((post) => [
|
|
36
|
+
posts.map((post) => [
|
|
37
|
+
post,
|
|
38
|
+
toTimestamp(post.data.updated || post.data.date, stalux.timezone),
|
|
39
|
+
]),
|
|
37
40
|
);
|
|
38
41
|
const sortedPosts = posts.toSorted((a, b) => {
|
|
39
42
|
// Newest first: compare post dates, use the greater of updated/date
|
|
@@ -49,7 +52,7 @@ export async function buildFeedItems(
|
|
|
49
52
|
|
|
50
53
|
let customData = "";
|
|
51
54
|
if (post.data.updated) {
|
|
52
|
-
const parsed = parseDate(post.data.updated);
|
|
55
|
+
const parsed = parseDate(post.data.updated, stalux.timezone);
|
|
53
56
|
if (isValid(parsed)) {
|
|
54
57
|
const updatedIso = formatInTimeZone(
|
|
55
58
|
parsed,
|
|
@@ -7,5 +7,6 @@ import { formatInTimeZone, parseDate } from "@utils/dayjs";
|
|
|
7
7
|
* @returns 带时区的 ISO 8601 格式字符串
|
|
8
8
|
*/
|
|
9
9
|
export function toMachineDateTime(dateStr: string, timezone: string): string {
|
|
10
|
-
|
|
10
|
+
// 关键:无时区的原始串必须按站点时区解析,不能按构建机器本地时区
|
|
11
|
+
return formatInTimeZone(parseDate(dateStr, timezone), timezone, "yyyy-MM-dd'T'HH:mm:ssXXX");
|
|
11
12
|
}
|