Initial commit

This commit is contained in:
Endeavorance 2024-05-08 11:09:37 -04:00
commit d745577d6a
10 changed files with 2631 additions and 0 deletions

17
.eslintrc.json Normal file
View file

@ -0,0 +1,17 @@
{
"env": {
"es2021": true
},
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/recommended",
"prettier"
],
"parser": "@typescript-eslint/parser",
"parserOptions": {
"ecmaVersion": "latest",
"sourceType": "module"
},
"plugins": ["@typescript-eslint"],
"rules": {}
}

3
.gitignore vendored Normal file
View file

@ -0,0 +1,3 @@
node_modules
.DS_Store
bin

1
LICENCE.md Normal file
View file

@ -0,0 +1 @@
Hammerstone © 2024 by Endeavorance is licensed under CC BY-NC-SA 4.0. To view a copy of this license, visit https://creativecommons.org/licenses/by-nc-sa/4.0/

3
README.md Normal file
View file

@ -0,0 +1,3 @@
# Hammerstone
Load and manipulate Obsidian vault data

2234
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

40
package.json Normal file
View file

@ -0,0 +1,40 @@
{
"name": "@endeavorance/hammerstone",
"version": "0.0.1",
"description": "Load and manipulate Obsidian vault data",
"type": "module",
"exports": "./bin/hammerstone.js",
"types": "./bin/hammerstone.d.ts",
"scripts": {
"build": "npm run clean && tsc",
"clean": "shx rm -rf bin",
"dev": "npm run build && npm start",
"start": "node ./bin/hammerstone.js"
},
"keywords": [],
"author": "",
"license": "CC BY-NC-SA 4.0",
"devDependencies": {
"@types/lodash-es": "^4.17.12",
"@types/node": "^20.11.27",
"@types/slug": "^5.0.8",
"@typescript-eslint/eslint-plugin": "^7.2.0",
"@typescript-eslint/parser": "^7.2.0",
"eslint": "^8.57.0",
"eslint-config-prettier": "^9.1.0",
"prettier": "^3.2.5",
"shx": "^0.3.4",
"typescript": "^5.4.2"
},
"prettier": {
"bracketSpacing": true,
"trailingComma": "all"
},
"imports": {},
"dependencies": {
"glob": "^10.3.12",
"lodash-es": "^4.17.21",
"slug": "^9.0.0",
"yaml": "^2.4.2"
}
}

223
src/document.ts Normal file
View file

@ -0,0 +1,223 @@
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",
);
}
}

4
src/hammerstone.ts Normal file
View file

@ -0,0 +1,4 @@
import Vault from "./vault.js";
import MarkdownDocument, { type FrontmatterShape } from "./document.js";
export { Vault, MarkdownDocument, FrontmatterShape };

87
src/vault.ts Normal file
View file

@ -0,0 +1,87 @@
import { globSync } from "glob";
import MarkdownDocument from "./document.js";
/**
* Load documents from an Obsidian vault
* @param vaultPath The path to the vault to load
* @param ignorePatterns A glob pattern to ignore files for
* @returns A promise which resolves as an array of laoded Documents
*/
function loadVaultDocuments(
vaultPath: string,
ignorePatterns: string[] = [],
): MarkdownDocument[] {
const discoveredMarkdownDocuments = globSync(`${vaultPath}/**/*.md`, {
ignore: ignorePatterns,
});
const markdownDocuments: MarkdownDocument[] = [];
for (const filePath of discoveredMarkdownDocuments) {
const file = new MarkdownDocument(filePath, vaultPath);
if (markdownDocuments.some((check) => check.slug === file.slug)) {
throw new Error("Duplicate slug: " + file.slug);
}
markdownDocuments.push(file);
}
return markdownDocuments;
}
/**
* Build a VaultIndex from a list of loaded Documents
* @param vaultDocs An array of all the documents to index
* @returns A VaultIndex, which is an object that maps document slugs to information
*/
function buildVaultIndex(
vaultDocs: MarkdownDocument[],
): Record<string, MarkdownDocument> {
const index: Record<string, MarkdownDocument> = {};
for (const document of vaultDocs) {
index[document.slug] = document;
}
return index;
}
interface VaultOptions {
ignorePatterns?: string[];
}
export default class Vault {
documents: MarkdownDocument[] = [];
slugs: string[] = [];
index: Record<string, MarkdownDocument> = {};
readonly vaultPath: string;
readonly size: number = 0;
private ignorePatterns: string[] = [];
constructor(vaultRootPath: string, options?: VaultOptions) {
this.vaultPath = vaultRootPath;
this.ignorePatterns = options?.ignorePatterns ?? [];
const allFiles = loadVaultDocuments(this.vaultPath, this.ignorePatterns);
const allSlugs = allFiles.map((doc) => doc.slug);
this.documents = allFiles;
this.slugs = allSlugs;
this.size = allFiles.length;
this.index = buildVaultIndex(allFiles);
}
map(fn: (document: MarkdownDocument) => MarkdownDocument) {
this.documents = this.documents.map(fn);
return this;
}
write() {
this.documents.forEach((doc) => {
doc.write();
});
}
}

19
tsconfig.json Normal file
View file

@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "es2021",
"lib": ["es2021"],
"module": "NodeNext",
"moduleResolution": "NodeNext",
"rootDir": "./src",
"baseUrl": "./src",
"paths": {},
"outDir": "./bin",
"forceConsistentCasingInFileNames": true,
"strict": true,
"skipLibCheck": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"declaration": true,
"noUncheckedIndexedAccess": true
},
}