Skip to content

Commit 3c6d97e

Browse files
Unify datetime handling for blog
1 parent b595ea2 commit 3c6d97e

8 files changed

Lines changed: 283 additions & 136 deletions

File tree

lib/buildInfo.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ export type CommitEntry = {
1414
commitHash: string;
1515
readonly shortCommitHash: string;
1616
author: string;
17-
timestamp: string;
17+
timestamp: Date;
1818
message: string;
1919
};
2020

lib/posts.ts

Lines changed: 74 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,52 +1,115 @@
11
import { CommitEntry } from "@/lib/buildInfo";
2+
import {
3+
fromISODateString,
4+
Serialized,
5+
toISODateString,
6+
} from "@/lib/utils";
27

38
type MetadataArgs = {
49
id: string;
510
title: string;
6-
tags?: string[];
11+
tags: string[];
712
buildInfo: {
813
isDirty: boolean;
914
currentCommit: CommitEntry;
1015
firstCommit: CommitEntry;
1116
};
12-
version?: number;
13-
createdTimestamp?: string;
14-
modifiedTimestamp?: string;
17+
createdTimestamp: Date | null;
18+
modifiedTimestamp: Date | null;
1519
};
1620

17-
export class Metadata {
21+
export class PostMetadata {
1822
id: string;
1923
title: string;
2024
tags: string[];
21-
createdTimestamp: string;
22-
modifiedTimestamp: string;
25+
createdTimestamp: Date;
26+
modifiedTimestamp: Date;
2327
buildInfo: MetadataArgs["buildInfo"];
24-
version?: number;
2528

2629
constructor({
2730
id,
2831
title,
2932
tags = [],
3033
buildInfo,
31-
version,
3234
createdTimestamp,
3335
modifiedTimestamp,
3436
}: MetadataArgs) {
3537
this.id = id;
3638
this.title = title;
3739
this.tags = tags;
3840
this.buildInfo = buildInfo;
39-
this.version = version;
4041
this.createdTimestamp =
4142
createdTimestamp ?? this.buildInfo.firstCommit.timestamp;
4243
this.modifiedTimestamp =
4344
modifiedTimestamp ?? this.buildInfo.currentCommit.timestamp;
4445
}
4546
}
47+
4648
export type Post = {
47-
metadata: Metadata;
49+
metadata: PostMetadata;
4850
content: {
4951
head: string;
5052
body: string;
5153
}; // Rendered file contents, most likely as rendered html
5254
};
55+
56+
export type SerializedPostMetadata = Serialized<PostMetadata>;
57+
export type SerializedPost = Serialized<Post>;
58+
59+
function serializeCommitEntry(entry: CommitEntry): Serialized<CommitEntry> {
60+
return {
61+
...entry,
62+
timestamp: toISODateString(entry.timestamp),
63+
};
64+
}
65+
66+
function deserializeCommitEntry(entry: Serialized<CommitEntry>): CommitEntry {
67+
return {
68+
...entry,
69+
timestamp: fromISODateString(entry.timestamp),
70+
};
71+
}
72+
73+
export function serializePostMetadata(
74+
metadata: PostMetadata,
75+
): SerializedPostMetadata {
76+
return {
77+
...metadata,
78+
createdTimestamp: toISODateString(metadata.createdTimestamp),
79+
modifiedTimestamp: toISODateString(metadata.modifiedTimestamp),
80+
buildInfo: {
81+
...metadata.buildInfo,
82+
currentCommit: serializeCommitEntry(metadata.buildInfo.currentCommit),
83+
firstCommit: serializeCommitEntry(metadata.buildInfo.firstCommit),
84+
},
85+
};
86+
}
87+
88+
export function deserializePostMetadata(
89+
metadata: SerializedPostMetadata,
90+
): PostMetadata {
91+
return new PostMetadata({
92+
...metadata,
93+
createdTimestamp: fromISODateString(metadata.createdTimestamp),
94+
modifiedTimestamp: fromISODateString(metadata.modifiedTimestamp),
95+
buildInfo: {
96+
...metadata.buildInfo,
97+
currentCommit: deserializeCommitEntry(metadata.buildInfo.currentCommit),
98+
firstCommit: deserializeCommitEntry(metadata.buildInfo.firstCommit),
99+
},
100+
});
101+
}
102+
103+
export function serializePost(post: Post): SerializedPost {
104+
return {
105+
...post,
106+
metadata: serializePostMetadata(post.metadata),
107+
};
108+
}
109+
110+
export function deserializePost(post: SerializedPost): Post {
111+
return {
112+
...post,
113+
metadata: deserializePostMetadata(post.metadata),
114+
};
115+
}

lib/server-only/buildInfo.ts

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -20,16 +20,21 @@ export async function getBuildInfo(): Promise<BuildInfo> {
2020
}
2121

2222
export function getCommitEntryForFile(filepath: string, head: boolean = true) {
23-
const commitEntryPattern = /^([a-f0-9]+) - ([^,]+), ([^:]+) : (.+)$/;
23+
// Prior style for reference: "%H - %an, %at : %s"
24+
// Example: "8fd2212f... - Michael Fortunato, 1739958235 : tighten static props typing"
25+
const commitEntryFormat = "%H%x1f%an%x1f%aI%x1f%s";
2426
const cmd = !head
25-
? `git log --reverse --pretty=format:"%H - %an, %at : %s" -- ${filepath} | head -1`
26-
: `git log -1 --pretty=format:"%H - %an, %at : %s" -- ${filepath}`;
27+
? `git log --reverse --pretty=format:"${commitEntryFormat}" -- ${filepath} | head -1`
28+
: `git log -1 --pretty=format:"${commitEntryFormat}" -- ${filepath}`;
2729
const commitEntry = launchShellCmd(cmd)?.toString().trim();
28-
if (commitEntry == undefined) return undefined;
29-
const [, commitHash, author, timestamp, message] =
30-
commitEntry.match(commitEntryPattern) || [];
31-
if (!(commitHash && author && timestamp && message)) {
32-
return undefined;
30+
if (commitEntry == null) return null;
31+
const [commitHash, author, authorDateISO, message] = commitEntry.split("\x1f");
32+
if (!(commitHash && author && authorDateISO && message)) {
33+
return null;
34+
}
35+
const timestamp = new Date(authorDateISO);
36+
if (Number.isNaN(timestamp.valueOf())) {
37+
return null;
3338
}
3439
return {
3540
commitHash,

lib/server-only/posts.ts

Lines changed: 42 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,8 @@ import {
1010
getGitDir,
1111
isDirty as isDirtyFunc,
1212
} from "./buildInfo";
13-
import { Metadata, Post } from "@/lib/posts";
13+
import { PostMetadata, Post } from "@/lib/posts";
14+
import { parseTimestamp, unwrap } from "@/lib/utils"
1415

1516
export function getCommitInfoForFileOrFallback(filepath: string) {
1617
const firstCommit = getCommitEntryForFile(filepath, false);
@@ -20,14 +21,14 @@ export function getCommitInfoForFileOrFallback(filepath: string) {
2021
return { isDirty, firstCommit, currentCommit };
2122
}
2223

23-
const nowSeconds = Math.floor(Date.now() / 1000).toString();
24+
const now = new Date()
2425
const fallbackCommit = {
2526
commitHash: "UNCOMMITTED",
2627
get shortCommitHash() {
2728
return this.commitHash.slice(0, 7);
2829
},
2930
author: "Michael Fortunato (unverified)",
30-
timestamp: nowSeconds,
31+
timestamp: now,
3132
message: "Uncommited/non-existant file",
3233
};
3334

@@ -46,11 +47,34 @@ function isRecord(v: unknown): v is Record<string, unknown> {
4647
return typeof v === "object" && v !== null;
4748
}
4849

49-
function asString(v: unknown): string | undefined {
50+
function asString(v: unknown): string | null {
5051
if (typeof v === "string") return v;
5152
if (isRecord(v) && typeof v.value === "string") return v.value;
5253
if (isRecord(v) && typeof v.text === "string") return v.text;
53-
return undefined;
54+
return null;
55+
}
56+
57+
function asNumber(v: unknown): number | null {
58+
if (typeof v === "number") return Number(v);
59+
return null;
60+
}
61+
62+
function asBoolean(v: unknown): boolean | null {
63+
if (typeof v === "boolean") return v;
64+
// There must be a better way than this...
65+
const vs = asString(v)
66+
if (vs === null) {
67+
return null
68+
}
69+
const t = vs.trim().toLowerCase();
70+
if (t === "true") return true;
71+
if (t === "false") return false;
72+
return null;
73+
}
74+
75+
function asTimestamp(v: unknown): Date | null {
76+
const ms_since_unix_epoch = asNumber(v)
77+
return parseTimestamp(ms_since_unix_epoch)
5478
}
5579

5680
function asStringArray(v: unknown): string[] {
@@ -89,7 +113,10 @@ function findLabelValue(items: unknown[], ...labels: string[]): unknown {
89113
// --- typst query + parse ---
90114
export async function _typstQuery(typstFilepath: string): Promise<unknown[]> {
91115
const TYPST_QUERY =
92-
"selector(<KEYWORDS>).or(<keywords>).or(<tags>).or(<TITLE>).or(<title>)";
116+
"selector(<KEYWORDS>).or(<keywords>)"
117+
+ ".or(<tags>)"
118+
+ ".or(<TITLE>).or(<title>)"
119+
+ ".or(<CREATED_TIMESTAMP>).or(<MODIFIED_TIMESTAMP>)";
93120
const root = await getGitDir();
94121
const { stdout } = await execFileAsync("typst", [
95122
"query",
@@ -154,6 +181,8 @@ export async function _typstFileToMetadata(typstFilepath: string) {
154181
id: (await idFromPostPath(typstFilepath)) || "<UNKNOWN ID>",
155182
title: asString(findLabelValue(items, "TITLE")) || "<UNKNOWN TITLE>",
156183
tags: asStringArray(findLabelValue(items, "KEYWORDS", "TAGS")),
184+
createdTimestamp: asTimestamp(findLabelValue(items, "CREATED_TIMESTAMP")),
185+
modifiedTimestamp: asTimestamp(findLabelValue(items, "MODIFIED_TIMESTAMP")),
157186
};
158187
}
159188
function splitHeadBody(html: string) {
@@ -207,15 +236,17 @@ export async function listPostIds(): Promise<string[]> {
207236
return Promise.all(files.map((file) => idFromPostPath(file)));
208237
}
209238

210-
export async function listPosts(): Promise<Metadata[]> {
239+
export async function listPosts(): Promise<PostMetadata[]> {
211240
const postFiles = await listPostFiles();
212241
const postsWithMetadata = await Promise.all(
213242
postFiles.map(async (postFile) => {
214-
const { id, title, tags } = await _typstFileToMetadata(postFile);
243+
const { id, title, tags, createdTimestamp, modifiedTimestamp } = await _typstFileToMetadata(postFile);
215244
const buildInfo = getCommitInfoForFileOrFallback(postFile);
216-
return new Metadata({
245+
return new PostMetadata({
217246
id,
218247
title,
248+
createdTimestamp,
249+
modifiedTimestamp,
219250
tags,
220251
buildInfo,
221252
});
@@ -227,9 +258,9 @@ export async function listPosts(): Promise<Metadata[]> {
227258
export async function buildPost(inputFilepath: string): Promise<Post> {
228259
const htmlString = await _typstFileToHTMLFile(inputFilepath);
229260
const headAndBody = splitHeadBody(htmlString);
230-
const { id, title, tags } = await _typstFileToMetadata(inputFilepath);
261+
const { id, title, tags, createdTimestamp, modifiedTimestamp } = await _typstFileToMetadata(inputFilepath);
231262
const buildInfo = getCommitInfoForFileOrFallback(inputFilepath);
232-
const metadata = new Metadata({ id, title, tags, buildInfo });
263+
const metadata = new PostMetadata({ id, title, createdTimestamp, modifiedTimestamp, tags, buildInfo });
233264
return {
234265
content: headAndBody,
235266
metadata,

lib/utils.ts

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,86 @@ import { twMerge } from "tailwind-merge";
44
export function cn(...inputs: ClassValue[]) {
55
return twMerge(clsx(inputs));
66
}
7+
8+
export type ISODateString = string & { readonly __isoDateStringBrand: true };
9+
10+
type Primitive = string | number | boolean | null | undefined;
11+
12+
export type Serialized<T> = T extends Date
13+
? ISODateString
14+
: T extends Primitive
15+
? T
16+
: T extends readonly (infer U)[]
17+
? Serialized<U>[]
18+
: T extends object
19+
? { [K in keyof T]: Serialized<T[K]> }
20+
: never;
21+
22+
export function toISODateString(value: Date): ISODateString {
23+
return value.toISOString() as ISODateString;
24+
}
25+
26+
export function fromISODateString(value: ISODateString | string): Date {
27+
return new Date(value);
28+
}
29+
30+
// AI Slop
31+
export type Option<T> = T | null;
32+
export const None: null = null;
33+
export const Some = <T>(x: T): Option<T> => x;
34+
35+
export const isSome = <T>(o: Option<T>): o is T => o !== null;
36+
export const map = <T, U>(o: Option<T>, f: (x: T) => U): Option<U> =>
37+
o === null ? null : f(o);
38+
export const andThen = <T, U>(o: Option<T>, f: (x: T) => Option<U>): Option<U> =>
39+
o === null ? null : f(o);
40+
export const unwrapOr = <T>(o: Option<T>, d: T): T => (o === null ? d : o);
41+
export const unwrap = <T>(o: Option<T>): T => {
42+
if (o === null)
43+
throw "Error"
44+
return o;
45+
}
46+
47+
48+
/**
49+
* NOTE: This was AI Generated.
50+
*
51+
* WARN: If you give a string that looks like a number
52+
* (specifically the numbers of ms since unix birth), we will not parse it.
53+
*
54+
* Parses a timestamp-like input into a valid `Date`.
55+
*
56+
* Happy-path inputs:
57+
* - `Date`: returned directly if valid.
58+
* - `number`: treated as Unix epoch milliseconds (same semantics as
59+
`new Date(ms)`).
60+
* - `string`: must be parseable by the JS `Date` constructor; prefer
61+
ISO-8601
62+
* (e.g. `"2026-02-19T18:45:00.000Z"`).
63+
*
64+
* Notes:
65+
* - Unix epoch seconds are not accepted directly; convert first with
66+
`seconds * 1000`.
67+
* - Returns `null` for `null`, unsupported types, or invalid/
68+
unparseable date values.
69+
*/
70+
export function parseTimestamp(timestamp: unknown): Date | null {
71+
72+
if (timestamp === null || (!(timestamp instanceof Date)) && typeof timestamp !== "number" && typeof timestamp !== "string") {
73+
return null
74+
}
75+
const date = timestamp instanceof Date ? timestamp : new Date(timestamp);
76+
return Number.isNaN(date.valueOf()) ? null : date;
77+
}
78+
79+
80+
function happenedSameYear(ts1: Date, ts2: Date): boolean {
81+
return ts1.getFullYear() == ts2.getFullYear()
82+
}
83+
84+
function happenedSameMonth(ts1: Date, ts2: Date): boolean {
85+
return happenedSameYear(ts1, ts2) && (ts1.getMonth() == ts2.getMonth())
86+
}
87+
export function happenedSameDay(ts1: Date, ts2: Date): boolean {
88+
return happenedSameMonth(ts1, ts2) && (ts1.getDate() == ts2.getDate())
89+
}

0 commit comments

Comments
 (0)