diff --git a/package.json b/package.json index 4328adc..ece7ccc 100644 --- a/package.json +++ b/package.json @@ -38,7 +38,8 @@ "github-slugger": "^2.0.0", "isomorphic-git": "^1.27.1", "ramda": "^0.30.1", - "yaml": "^2.6.0" + "yaml": "^2.6.0", + "zod": "^3.23.8" }, "packageManager": "pnpm@9.1.1+sha256.9551e803dcb7a1839fdf5416153a844060c7bce013218ce823410532504ac10b" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8da3a8b..c83bac7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -29,6 +29,9 @@ importers: yaml: specifier: ^2.6.0 version: 2.6.0 + zod: + specifier: ^3.23.8 + version: 3.23.8 devDependencies: '@eslint/js': specifier: ^9.12.0 @@ -1530,6 +1533,9 @@ packages: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} + zod@3.23.8: + resolution: {integrity: sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==} + snapshots: '@ampproject/remapping@2.3.0': @@ -2853,3 +2859,5 @@ snapshots: yaml@2.6.0: {} yocto-queue@0.1.0: {} + + zod@3.23.8: {} diff --git a/src/transform/index.ts b/src/transform/index.ts index ccbd323..572534b 100644 --- a/src/transform/index.ts +++ b/src/transform/index.ts @@ -1,9 +1,18 @@ -import type { TFile, Pos } from "obsidian"; +import type { + TFile, + Pos, + CachedMetadata, + FrontMatterCache, + App, + Notice, +} from "obsidian"; import { ImageTransformer } from "@/Image/base"; -import type { TransformCtx, Entry, Post } from "@/type"; import { processLinks } from "./link"; import { transformTag } from "./tag"; import { getImageTransformer } from "@/Image"; +import { validateMeta } from "./validate"; +import { z } from "zod"; +import { SSSettings } from "@/type"; /* Transform pipeline: @@ -20,6 +29,34 @@ Transform pipeline: - Run all functions in "actions" array */ +export type CachedMetadataPost = Omit< + CachedMetadata, + "frontmatter" | "frontmatterPosition" +> & { + frontmatter: z.infer & FrontMatterCache; + frontmatterPosition: NonNullable; +}; +export const frontmatterSchema = z + .object({ + slug: z.string(), + published: z.literal(true), + }) + .passthrough(); + +export interface TransformCtx { + cachedRead: App["vault"]["cachedRead"]; + readBinary: App["vault"]["readBinary"]; + getFileMetadata: App["metadataCache"]["getFileCache"]; + resolveLink: App["metadataCache"]["getFirstLinkpathDest"]; + notice: (...args: ConstructorParameters) => void; + settings: SSSettings; +} + +export interface Post { + tFile: TFile; + content: string; + meta: CachedMetadataPost; +} export type TransformCtxWithImage = TransformCtx & { imageTf: ImageTransformer; }; @@ -46,9 +83,11 @@ export async function transform( ))(ctx); await imageTf.onBeforeTransform(); const ctxWithImage: TransformCtxWithImage = { ...ctx, imageTf }; + const transformedPosts = await Promise.all( originalPosts.map((post) => normalizePost(post, ctxWithImage)), ); + await imageTf.onFinish(); return transformedPosts; } @@ -69,29 +108,16 @@ const readAndFilterValidPosts = async ( postFiles.map(async (tFile: TFile) => ({ tFile: tFile, content: await ctx.cachedRead(tFile), - meta: ctx.getFileMetadata(tFile), + meta: validateMeta(tFile, ctx), })), ) - ) - .map((post) => validateEntry(post, ctx)) - .filter((post): post is Post => post !== undefined); - -/** - * Validates the given entry to ensure it has the necessary metadata. - * - * @param post - The entry to validate. - * @returns The validated post if it contains the required metadata, otherwise `undefined`. - */ -function validateEntry(post: Entry, ctx: TransformCtx): Post | undefined { - if (!(post.meta && post.meta.frontmatterPosition && post.meta.frontmatter)) - return undefined; - if (!post.meta.frontmatter.published) return undefined; - if (!post.meta.frontmatter.slug) { - ctx.notice(`Post "${post.tFile.name}" does not have slug`); - return undefined; - } - return post as Post; -} + ).filter((post): post is Post => { + if (post.meta instanceof Error) { + ctx.notice(`Error validating "${post.tFile.path}": ${post.meta.message}`); + return false; + } + return true; + }); async function normalizePost( post: Post, diff --git a/src/transform/link.ts b/src/transform/link.ts index 48cb0a3..0b5be37 100644 --- a/src/transform/link.ts +++ b/src/transform/link.ts @@ -2,6 +2,9 @@ import type { Post } from "@/type"; import type { ReferenceCache, TFile } from "obsidian"; import { slug as slugger } from "github-slugger"; import type { TransformAction, TransformCtxWithImage } from "@/transform/index"; +import { validateMeta } from "./validate"; + +const IMAGE_EXT = ["png", "jpg", "jpeg", "gif", "svg", "webp"]; export async function processLinks( post: Post, @@ -25,58 +28,63 @@ export async function processLinks( }, ]; } - +/** + * Case 1: target not found / target is not md nor image / target not valid post (not published) + * - link.original + * Case 2: target is image + * - imageTf.onTransform + * Case 3: target is another post + * - normal link + * @param link + * @param sourceTFile + * @param ctx + * @returns + */ async function transformLink( link: ReferenceCache, sourceTFile: TFile, ctx: TransformCtxWithImage, ): Promise { const targetFile = ctx.resolveLink(link.link.split("#")[0], sourceTFile.path); + if (targetFile == null) { console.warn(`link not found:`, link.original); return link.original; } - const imgExt = ["png", "jpg", "jpeg", "gif", "svg", "webp"]; - if (imgExt.includes(targetFile.extension)) { - { - return await ctx.imageTf.onTransform(link, sourceTFile, targetFile); - } - } else if (targetFile.extension === "md") { - { - const slug = getSlug(targetFile, ctx); - const displayText = link.displayText ?? link.link; - if (slug) { - const fragment = link.link.includes("#") - ? slugger(link.link.split("#")[1]) - : undefined; - if (targetFile.path === sourceTFile.path && fragment) { - return `[${displayText}](#${fragment})`; - } else { - return `[${displayText}](${slugToPath(slug, ctx) + (fragment ? "#" + fragment : "")})`; - } - } else { - console.warn( - `link target not published: ${targetFile.name} from ${sourceTFile.name}`, - ); - return link.original; - } - } - } else { - { - console.error( - `unknown ext ${targetFile.extension} for ${targetFile.name}`, - ); + if (IMAGE_EXT.includes(targetFile.extension)) + return await ctx.imageTf.onTransform(link, sourceTFile, targetFile); + + if (targetFile.extension === "md") { + const slug = getSlug(targetFile, ctx); + + // target not published + if (!slug) { + console.warn( + `link target not published: "${targetFile.name}" from "${sourceTFile.name}"`, + ); return link.original; } + + const displayText = link.displayText ?? link.link; + const fragment = link.link.includes("#") + ? "#" + slugger(link.link.split("#").slice(1).join("#")) + : ""; + if (targetFile.path === sourceTFile.path && fragment) { + return `[${displayText}](${fragment})`; + } else { + return `[${displayText}](${slugToPath(slug, ctx)}${fragment})`; + } } + + console.error(`unknown ext ${targetFile.extension} for ${targetFile.name}`); + return link.original; } function getSlug(file: TFile, ctx: TransformCtxWithImage): string | null { - const { published, slug } = ctx.getFileMetadata(file)?.frontmatter ?? {}; - - if (!published) return null; - return slug ?? null; + const meta = validateMeta(file, ctx); + if (meta instanceof Error) return null; + return meta.frontmatter.slug; } function slugToPath(slug: string, ctx: TransformCtxWithImage): string { diff --git a/src/transform/validate.ts b/src/transform/validate.ts new file mode 100644 index 0000000..8595ccb --- /dev/null +++ b/src/transform/validate.ts @@ -0,0 +1,40 @@ +import { CachedMetadataPost, frontmatterSchema } from "."; +import { App, TFile } from "obsidian"; +export class PostValidationError extends Error { + tFile: TFile; + constructor(message: string, tFile: TFile) { + super(message); + this.tFile = tFile; + } +} + +function formatZodError( + error: NonNullable< + ReturnType<(typeof frontmatterSchema)["safeParse"]>["error"] + >, +) { + const formattedError = error.issues.reduce((acc, issue) => { + const message = `"${issue.path.join(".")}": ${issue.message}`; + return acc + message + "\n"; + }, "\n"); + return formattedError; +} + +export function validateMeta( + file: TFile, + ctx: { + getFileMetadata: App["metadataCache"]["getFileCache"]; + }, +): CachedMetadataPost | PostValidationError { + if (file.extension !== "md") + return new PostValidationError("Not a markdown file", file); + + const meta = ctx.getFileMetadata(file); + if (!meta || !meta.frontmatter || !meta.frontmatterPosition) + return new PostValidationError("No frontmatter", file); + + const { error } = frontmatterSchema.safeParse(meta.frontmatter); + if (error) return new PostValidationError(formatZodError(error), file); + + return meta as CachedMetadataPost; +} diff --git a/src/type.ts b/src/type.ts index 6df7820..ded820a 100644 --- a/src/type.ts +++ b/src/type.ts @@ -1,30 +1,2 @@ -import { App, CachedMetadata, TFile, Notice } from "obsidian"; -import type { SSSettings } from "@/Settings"; - -export interface Entry { - tFile: TFile; - content: string; - meta: CachedMetadata | null; -} - -export interface TransformCtx { - cachedRead: App["vault"]["cachedRead"]; - readBinary: App["vault"]["readBinary"]; - getFileMetadata: App["metadataCache"]["getFileCache"]; - resolveLink: App["metadataCache"]["getFirstLinkpathDest"]; - notice: (...args: ConstructorParameters) => void; - settings: SSSettings; -} - -type CachedMetadataPost = Omit< - CachedMetadata, - "frontmatter" | "frontmatterPosition" -> & { - frontmatter: NonNullable; - frontmatterPosition: NonNullable; -}; -export interface Post extends Entry { - meta: CachedMetadataPost; -} - export type { SSSettings } from "./Settings"; +export type { Post } from "./transform"; diff --git a/src/utils/stringifyPost.ts b/src/utils/stringifyPost.ts index e59d6de..1c8bf65 100644 --- a/src/utils/stringifyPost.ts +++ b/src/utils/stringifyPost.ts @@ -4,8 +4,7 @@ import { stringify } from "yaml"; export const stringifyPost = ( post: Post, ): { filename: string; content: string } => { - const filename: string = - (post.meta.frontmatter.slug ?? post.tFile.basename) + ".md"; + const filename: string = post.meta.frontmatter.slug + ".md"; const frontmatter = stringify(post.meta.frontmatter); const content = `---\n${frontmatter}---\n\n` + post.content.trim();