hammerstone/src/document.ts
2024-05-08 11:09:37 -04:00

224 lines
5.8 KiB
TypeScript

import { existsSync, readFileSync, writeFileSync } from "fs";
import path from "node:path";
import slug from "slug";
import YAML from "yaml";
export type FrontmatterShape = Record<string, string | string[]>;
const FRONTMATTER_REGEX = /^---[\s\S]*?---/gm;
function getBlockTags(blockName: string): [string, string] {
return [`%% {${blockName}} %%`, `%% {/${blockName}} %%`];
}
function documentContainsBlock(document: MarkdownDocument, blockName: string) {
const [openTag, closeTag] = getBlockTags(blockName);
const openLoc = document.content.indexOf(openTag);
const closeLoc = document.content.indexOf(closeTag);
if (openLoc === -1 || closeLoc === -1) {
return false;
}
if (closeLoc < openLoc) {
return false;
}
return true;
}
function readDocumentBlock(
document: MarkdownDocument,
blockName: string,
): string | undefined {
const [openTag, closeTag] = getBlockTags(blockName);
const openLoc = document.content.indexOf(openTag);
const closeLoc = document.content.indexOf(closeTag);
if (openLoc === -1 || closeLoc === -1) {
return undefined;
}
return document.content.slice(openLoc + openTag.length, closeLoc).trim();
}
function writeDocumentBlock(
document: MarkdownDocument,
blockName: string,
blockContent: string = "",
): MarkdownDocument {
const [openTag, closeTag] = getBlockTags(blockName);
const openLoc = document.content.indexOf(openTag);
const closeLoc = document.content.indexOf(closeTag);
if (openLoc === -1 || closeLoc === -1) {
return document;
}
const newContent =
document.content.slice(0, openLoc + openTag.length) +
"\n" +
blockContent +
"\n" +
document.content.slice(closeLoc);
document.content = newContent;
return document;
}
/**
* Attempt to parse YAML frontmatter from a markdown document
* @param contents The raw markdown content
* @returns Any successfully parsed frontmatter
*/
function processDocumentFrontmatter(contents: string): FrontmatterShape {
if (contents.trim().indexOf("---") !== 0) {
return {};
}
let frontmatterData = {};
if (FRONTMATTER_REGEX.test(contents)) {
const frontmatterString = contents.match(FRONTMATTER_REGEX)![0];
const cleanFrontmatter = frontmatterString.replaceAll("---", "").trim();
frontmatterData = YAML.parse(cleanFrontmatter);
}
return {
...frontmatterData,
};
}
function getDocumentMarkdown(content: string): string {
if (content.trim().indexOf("---") !== 0) {
return content;
}
return content.replace(FRONTMATTER_REGEX, "").trim();
}
function combineMarkdownAndFrontmatter(
markdown: string,
frontmatter: FrontmatterShape,
) {
return `---
${YAML.stringify(frontmatter)}
---
${markdown.trim()}
`;
}
export default class MarkdownDocument {
/** The original file path to this document */
readonly path: string;
/** The path to the root of the vault */
readonly vaultRootPath: string;
/** The directory/folder that this Document is in, relative to the vault root */
readonly dirname: string;
readonly taxonomy: string[];
/** The name of this document's file, without the file extension */
readonly filename: 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<string, unknown> = {};
/** The markdown portion of the file (without frontmatter) */
private _markdown: string;
constructor(filePath: string, vaultPath: string) {
if (!existsSync(filePath)) {
throw new Error(`File not found: ${filePath}`);
}
const rawFileContent = readFileSync(filePath, "utf-8");
const fileDirname = path.dirname(filePath);
this.path = filePath;
this.dirname = path.relative(vaultPath, fileDirname);
this.taxonomy = this.dirname.split(path.sep);
this.vaultRootPath = vaultPath;
this.filename = path.basename(filePath, ".md");
this.slug = slug(`${this.taxonomy.join("-")}-${this.filename}`);
this._content = rawFileContent;
this.contentHistory = [rawFileContent];
this._frontmatter = processDocumentFrontmatter(rawFileContent);
this._markdown = getDocumentMarkdown(rawFileContent);
}
containsBlock(blockName: string): boolean {
return documentContainsBlock(this, blockName);
}
readBlock(blockName: string): string | undefined {
return readDocumentBlock(this, blockName);
}
setBlockContent(blockName: string, newContent: string): MarkdownDocument {
return writeDocumentBlock(this, blockName, newContent);
}
set markdown(newValue: string) {
this._markdown = newValue;
this._content = combineMarkdownAndFrontmatter(newValue, this.frontmatter);
this.contentHistory.push(this._content);
}
/** The markdown portion of the file (without frontmatter) */
get markdown() {
return this._markdown;
}
set frontmatter(newValue: FrontmatterShape) {
this._frontmatter = newValue;
this._content = combineMarkdownAndFrontmatter(
this._markdown,
this._frontmatter,
);
this.contentHistory.push(this._content);
}
/** The parsed frontmatter of the file */
get frontmatter() {
return this._frontmatter;
}
/** The raw content of the file */
get content() {
return this._content;
}
set content(newValue: string) {
this._content = newValue;
this._frontmatter = processDocumentFrontmatter(newValue);
this._markdown = getDocumentMarkdown(newValue);
this.contentHistory.push(this._content);
}
write() {
writeFileSync(
this.path,
combineMarkdownAndFrontmatter(this._markdown, this.frontmatter),
"utf-8",
);
}
}