import { existsSync, readFileSync, writeFileSync } from "fs"; import path from "node:path"; import slug from "slug"; import type Vault from "./vault.js"; import { combineMarkdownAndFrontmatter, extractFrontmatter, extractMarkdown, extractTags, type FrontmatterShape, } from "./markdown.js"; export default class MarkdownDocument { /** A reference to the vault containing this document */ readonly vault: Vault; /** The original file path to this document */ readonly path: string; /** The directory/folder that this Document is in, relative to the vault root */ readonly dirname: string; /** An array of directory names this document is found under */ readonly taxonomy: string[]; /** The name of this document's file, without the file extension */ readonly filename: string; /** An array of tags found in this document from both the markdown and frontmatter */ tags: string[]; /** A url-safe version of this file's name */ readonly slug: string; /** The raw content of the file */ private _content: string; /** The history of this files contents after each modification */ readonly contentHistory: string[]; /** The parsed frontmatter of the file */ private _frontmatter: FrontmatterShape; /** Structured document metadata */ meta: Record = {}; /** The markdown portion of the file (without frontmatter) */ private _markdown: string; constructor(filePath: string, vault: Vault) { if (!existsSync(filePath)) { throw new Error(`File not found: ${filePath}`); } const rawFileContent = readFileSync(filePath, "utf-8"); const fileDirname = path.dirname(filePath); const vaultPath = vault.vaultPath; const frontmatter = extractFrontmatter(rawFileContent); this.path = filePath; this.dirname = path.relative(vaultPath, fileDirname); this.taxonomy = this.dirname.split(path.sep); this.filename = path.basename(filePath, ".md"); this.slug = slug(this.filename); this._content = rawFileContent; this.contentHistory = [rawFileContent]; this._frontmatter = frontmatter; this._markdown = extractMarkdown(rawFileContent); this.vault = vault; const frontmatterTags = frontmatter.tags; const contentTags = extractTags(this._markdown); this.tags = frontmatterTags.concat(contentTags); } /** * @returns A serializable shape for this object */ toJSON() { return { path: this.path, dirname: this.dirname, taxonomy: this.taxonomy, filename: this.filename, slug: this.slug, frontmatter: this._frontmatter, markdown: this._markdown, }; } /** * Update the markdown content for the document * @param newValue The new markdown content for the document */ setMarkdown(newValue: string): MarkdownDocument { this._markdown = newValue; this._content = combineMarkdownAndFrontmatter(newValue, this.frontmatter); this.contentHistory.push(this._content); return this; } /** * Update the frontmatter of the document * @param newValue The new frontmatter shape for this document */ setFrontmatter(newValue: FrontmatterShape): MarkdownDocument { this._frontmatter = newValue; this._content = combineMarkdownAndFrontmatter(this._markdown, newValue); this.contentHistory.push(this._content); return this; } /** * Update the full content of the doc with a combination of markdown and yaml frontmatter * @param newValue The full content of the document to set */ setContent(newValue: string): MarkdownDocument { this._content = newValue; this._frontmatter = extractFrontmatter(newValue); this._markdown = extractMarkdown(newValue); this.contentHistory.push(this._content); return this; } /** * Check if this document has a specific tag * @param tag The tag to look for * @returns `true` If this document has the given tag */ hasTag(tag: string) { return this.tags.includes(tag); } /** * Given an array of directory names, return `true` if this document has the taxonomy * @param dirs Directories to check for in the taxonomy * @returns `true` If this document shares the given taxonomy */ hasTaxonomy(dirs: string[]) { if (dirs.length > this.taxonomy.length) { return false; } for (let i = 0; i < dirs.length; i++) { if (dirs[i] !== this.taxonomy[i]) { return false; } } return true; } /** * Revert this document to its original loaded content */ revert(): MarkdownDocument { const originalCopy = this.contentHistory[0]; if (originalCopy === undefined) { throw new Error("Document has no history to revert to."); } this.setContent(originalCopy); return this; } /** * Write this document back to disk */ write(): MarkdownDocument { writeFileSync( this.path, combineMarkdownAndFrontmatter(this._markdown, this.frontmatter), "utf-8", ); return this; } /** * Retrieves the value of a frontmatter key as a string. * If the key is not found, returns the provided default value. * Throws an error if the value is not a string. * * @param key - The key of the frontmatter value to retrieve. * @param defaultValue - The default value to return if the key is not found. * @returns The value of the frontmatter key as a string. * @throws Error if the frontmatter key is not a string. */ getFrontmatterString(key: string, defaultValue: string): string { const val = this._frontmatter[key]; if (val === undefined) { return defaultValue; } if (typeof val !== "string") { throw new Error(`Frontmatter key is not a string: ${key}`); } return val; } /** * Retrieves an array value from the frontmatter object based on the provided key. * If the value is not found, it returns the provided defaultValue. * If the value is found but is not an array, it throws an error. * * @param key - The key to retrieve the array value from the frontmatter object. * @param defaultValue - The default value to return if the key is not found in the frontmatter object. * @returns The array value associated with the provided key, or the defaultValue if the key is not found. * @throws Error if the value associated with the key is found but is not an array. */ getFrontmatterArray(key: string, defaultValue: string[]): string[] { const val = this._frontmatter[key]; if (val === undefined) { return defaultValue; } if (!Array.isArray(val)) { throw new Error(`Frontmatter key is not an array: ${key}`); } return val; } /** The markdown portion of the file (without frontmatter) */ get markdown() { return this._markdown; } /** The parsed frontmatter of the file */ get frontmatter() { return this._frontmatter; } /** The raw content of the file */ get content() { return this._content; } }