Initial commit
This commit is contained in:
commit
d745577d6a
17
.eslintrc.json
Normal file
17
.eslintrc.json
Normal 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
3
.gitignore
vendored
Normal file
|
@ -0,0 +1,3 @@
|
|||
node_modules
|
||||
.DS_Store
|
||||
bin
|
1
LICENCE.md
Normal file
1
LICENCE.md
Normal 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
3
README.md
Normal file
|
@ -0,0 +1,3 @@
|
|||
# Hammerstone
|
||||
|
||||
Load and manipulate Obsidian vault data
|
2234
package-lock.json
generated
Normal file
2234
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
40
package.json
Normal file
40
package.json
Normal 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
223
src/document.ts
Normal 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
4
src/hammerstone.ts
Normal 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
87
src/vault.ts
Normal 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
19
tsconfig.json
Normal 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
|
||||
},
|
||||
}
|
Loading…
Reference in a new issue