mirror of
https://github.com/joelhooks/joelclaw.git
synced 2026-09-19 01:24:04 +08:00
292 lines
8.3 KiB
TypeScript
292 lines
8.3 KiB
TypeScript
import { createHash } from "node:crypto";
|
|
import { ConvexHttpClient } from "convex/browser";
|
|
import matter from "gray-matter";
|
|
import { cacheLife, cacheTag } from "next/cache";
|
|
import { api } from "@/lib/convex-api";
|
|
import {
|
|
readConvexDeployKey,
|
|
readConvexUrl,
|
|
shouldDegradeStaticGenerationWithoutConvex,
|
|
warnMissingConvexDuringBuild,
|
|
} from "./convex-env";
|
|
import { compareDateDesc, toDateString } from "./date";
|
|
|
|
/**
|
|
* Post reader — Convex-only (ADR-0168).
|
|
* No filesystem fallback. Convex is the canonical read-side.
|
|
*/
|
|
|
|
export type ContentType = "article" | "essay" | "note" | "tutorial";
|
|
|
|
export type PostMeta = {
|
|
title: string;
|
|
date: string;
|
|
updated?: string;
|
|
description: string;
|
|
slug: string;
|
|
type: ContentType;
|
|
tags: string[];
|
|
source?: string;
|
|
channel?: string;
|
|
duration?: string;
|
|
draft?: boolean;
|
|
image?: string;
|
|
};
|
|
|
|
export type PostDiagnostics = {
|
|
source: "convex";
|
|
resourceId: string;
|
|
contentHash: string;
|
|
contentLength: number;
|
|
contentUpdatedAt?: number;
|
|
};
|
|
|
|
export type Post = {
|
|
meta: PostMeta;
|
|
content: string;
|
|
diagnostics: PostDiagnostics;
|
|
};
|
|
|
|
let convexClient: ConvexHttpClient | null | undefined;
|
|
|
|
function getConvexClient(): ConvexHttpClient | null {
|
|
if (convexClient !== undefined) return convexClient;
|
|
|
|
const convexUrl = readConvexUrl();
|
|
if (!convexUrl) {
|
|
if (shouldDegradeStaticGenerationWithoutConvex()) {
|
|
warnMissingConvexDuringBuild("post reads");
|
|
convexClient = null;
|
|
return convexClient;
|
|
}
|
|
|
|
throw new Error(
|
|
"CONVEX_URL or NEXT_PUBLIC_CONVEX_URL is required for article reads (ADR-0168: Convex is canonical)",
|
|
);
|
|
}
|
|
|
|
convexClient = new ConvexHttpClient(convexUrl);
|
|
|
|
const deployKey = readConvexDeployKey();
|
|
if (deployKey) {
|
|
(convexClient as ConvexHttpClient & { setAdminAuth: (token: string) => void }).setAdminAuth(deployKey);
|
|
}
|
|
|
|
return convexClient;
|
|
}
|
|
|
|
function asRecord(value: unknown): Record<string, unknown> {
|
|
if (!value || typeof value !== "object" || Array.isArray(value)) return {};
|
|
return value as Record<string, unknown>;
|
|
}
|
|
|
|
function asOptionalString(value: unknown): string | undefined {
|
|
return typeof value === "string" && value.trim().length > 0 ? value : undefined;
|
|
}
|
|
|
|
function asOptionalNumber(value: unknown): number | undefined {
|
|
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
|
}
|
|
|
|
function asStringArray(value: unknown): string[] {
|
|
if (!Array.isArray(value)) return [];
|
|
return value.filter((entry): entry is string => typeof entry === "string");
|
|
}
|
|
|
|
function isContentType(value: unknown): value is ContentType {
|
|
return value === "article" || value === "essay" || value === "note" || value === "tutorial";
|
|
}
|
|
|
|
function shouldIncludeDrafts(): boolean {
|
|
return process.env.NODE_ENV === "development";
|
|
}
|
|
|
|
function stripLeadingFrontmatter(content: string): string {
|
|
if (!content.startsWith("---")) return content;
|
|
|
|
try {
|
|
return matter(content).content;
|
|
} catch {
|
|
return content;
|
|
}
|
|
}
|
|
|
|
type ParsedPostFields = {
|
|
slug: string;
|
|
title: string;
|
|
date: string;
|
|
updated?: string;
|
|
description?: string;
|
|
type: ContentType;
|
|
tags: string[];
|
|
source?: string;
|
|
channel?: string;
|
|
duration?: string;
|
|
draft: boolean;
|
|
image?: string;
|
|
content?: string;
|
|
};
|
|
|
|
function parsePostFields(value: unknown, fallbackSlug?: string): ParsedPostFields | null {
|
|
const fields = asRecord(value);
|
|
const slug = asOptionalString(fields.slug) ?? fallbackSlug;
|
|
const title = asOptionalString(fields.title);
|
|
const date = toDateString(fields.date);
|
|
const rawContent = asOptionalString(fields.content);
|
|
const content = rawContent ? stripLeadingFrontmatter(rawContent) : undefined;
|
|
if (!slug || !title || !date) return null;
|
|
|
|
const typeValue = asOptionalString(fields.type);
|
|
const type = isContentType(typeValue) ? typeValue : "article";
|
|
const updated = toDateString(fields.updated) || undefined;
|
|
|
|
return {
|
|
slug,
|
|
title,
|
|
date,
|
|
updated,
|
|
description: asOptionalString(fields.description),
|
|
type,
|
|
tags: asStringArray(fields.tags),
|
|
source: asOptionalString(fields.source),
|
|
channel: asOptionalString(fields.channel),
|
|
duration: asOptionalString(fields.duration),
|
|
draft: fields.draft === true,
|
|
image: asOptionalString(fields.image),
|
|
content,
|
|
};
|
|
}
|
|
|
|
function toPostMeta(fields: ParsedPostFields): PostMeta {
|
|
return {
|
|
title: fields.title,
|
|
date: fields.date,
|
|
updated: fields.updated,
|
|
description: fields.description ?? "",
|
|
slug: fields.slug,
|
|
type: fields.type,
|
|
tags: fields.tags,
|
|
source: fields.source,
|
|
channel: fields.channel,
|
|
duration: fields.duration,
|
|
draft: fields.draft,
|
|
image: fields.image,
|
|
};
|
|
}
|
|
|
|
function contentFingerprint(content: string): string {
|
|
return createHash("sha1").update(content).digest("hex").slice(0, 12);
|
|
}
|
|
|
|
export async function getAllPosts(): Promise<PostMeta[]> {
|
|
"use cache";
|
|
cacheLife("days");
|
|
cacheTag("articles");
|
|
|
|
const convex = getConvexClient();
|
|
if (!convex) return [];
|
|
|
|
const [articleDocs, tutorialDocs, essayDocs, noteDocs] = await Promise.all([
|
|
convex.query(api.contentResources.listByType, { type: "article", limit: 2000 }),
|
|
convex.query(api.contentResources.listByType, { type: "tutorial", limit: 2000 }),
|
|
convex.query(api.contentResources.listByType, { type: "essay", limit: 2000 }),
|
|
convex.query(api.contentResources.listByType, { type: "note", limit: 2000 }),
|
|
]);
|
|
|
|
if (
|
|
!Array.isArray(articleDocs) ||
|
|
!Array.isArray(tutorialDocs) ||
|
|
!Array.isArray(essayDocs) ||
|
|
!Array.isArray(noteDocs)
|
|
) {
|
|
throw new Error("Convex listByType returned a non-array payload for post listing");
|
|
}
|
|
|
|
const docs = [...articleDocs, ...tutorialDocs, ...essayDocs, ...noteDocs];
|
|
|
|
return docs
|
|
.map((doc) => {
|
|
const fields = parsePostFields(asRecord(doc).fields);
|
|
if (!fields) return null;
|
|
if (!shouldIncludeDrafts() && fields.draft) return null;
|
|
return toPostMeta(fields);
|
|
})
|
|
.filter((post): post is PostMeta => post !== null)
|
|
.sort((a, b) => compareDateDesc(a.date, b.date));
|
|
}
|
|
|
|
export async function getPost(slug: string): Promise<Post | null> {
|
|
"use cache";
|
|
cacheLife("minutes");
|
|
cacheTag(`article:${slug}`);
|
|
cacheTag(`post:${slug}`);
|
|
|
|
const convex = getConvexClient();
|
|
if (!convex) return null;
|
|
|
|
let docRecord: Record<string, unknown> | null = null;
|
|
let matchedResourceId: string | null = null;
|
|
for (const type of ["article", "tutorial", "essay", "note"] as const) {
|
|
const resourceId = `${type}:${slug}`;
|
|
const doc = await convex.query(api.contentResources.getByResourceId, { resourceId });
|
|
if (!doc || typeof doc !== "object") continue;
|
|
docRecord = asRecord(doc);
|
|
matchedResourceId = resourceId;
|
|
break;
|
|
}
|
|
|
|
if (!docRecord || !matchedResourceId) return null;
|
|
|
|
const fields = parsePostFields(docRecord.fields, slug);
|
|
if (!fields?.content) return null;
|
|
if (!shouldIncludeDrafts() && fields.draft) return null;
|
|
|
|
return {
|
|
meta: toPostMeta(fields),
|
|
content: fields.content,
|
|
diagnostics: {
|
|
source: "convex",
|
|
resourceId: matchedResourceId,
|
|
contentHash: contentFingerprint(fields.content),
|
|
contentLength: fields.content.length,
|
|
contentUpdatedAt: asOptionalNumber(docRecord.updatedAt),
|
|
},
|
|
};
|
|
}
|
|
|
|
export async function getPostSlugs(): Promise<string[]> {
|
|
"use cache";
|
|
cacheLife("days");
|
|
cacheTag("articles");
|
|
|
|
const convex = getConvexClient();
|
|
if (!convex) return [];
|
|
|
|
const [articleDocs, tutorialDocs, essayDocs, noteDocs] = await Promise.all([
|
|
convex.query(api.contentResources.listByType, { type: "article", limit: 2000 }),
|
|
convex.query(api.contentResources.listByType, { type: "tutorial", limit: 2000 }),
|
|
convex.query(api.contentResources.listByType, { type: "essay", limit: 2000 }),
|
|
convex.query(api.contentResources.listByType, { type: "note", limit: 2000 }),
|
|
]);
|
|
|
|
if (
|
|
!Array.isArray(articleDocs) ||
|
|
!Array.isArray(tutorialDocs) ||
|
|
!Array.isArray(essayDocs) ||
|
|
!Array.isArray(noteDocs)
|
|
) {
|
|
throw new Error("Convex listByType returned a non-array payload for post slugs");
|
|
}
|
|
|
|
const docs = [...articleDocs, ...tutorialDocs, ...essayDocs, ...noteDocs];
|
|
|
|
return docs
|
|
.map((doc) => {
|
|
const fields = parsePostFields(asRecord(doc).fields);
|
|
if (!fields) return null;
|
|
if (!shouldIncludeDrafts() && fields.draft) return null;
|
|
return fields.slug;
|
|
})
|
|
.filter((entry): entry is string => Boolean(entry));
|
|
}
|