Add more tests and readme docs

This commit is contained in:
Endeavorance 2024-05-09 09:44:49 -04:00
parent bc5ddf3c23
commit d964f1149e
11 changed files with 367 additions and 69 deletions

View file

@ -1,3 +1,83 @@
# Hammerstone # Hammerstone
Load and manipulate Obsidian vault data Load and manipulate Obsidian vault data
## API
### `new Vault(vaultRootPath [, options])`
Create a new `Vault` object. Searches for markdown files in the given directory, loads, and parses frontmatter for each document.
#### Options
| Option | Description | Default |
| ---------------- | ---------------------------------------------------------- | ------- |
| `ignorePatterns` | An optional array of globs to ignore when discovering docs | `[]` |
### `vault.process(fn)`
Process all documents in the vault. Each document is passed to the provided function.
Returns a reference to the vault to allow for chaining of document processing flows.
### `vault.scope(fn)`
Returns a `VaultView` containing only the documents which the provided function returns a truthy value for (similar to `Array.filter()`).
A `VaultView` has the `.process()` function just as a `Vault` does. You can also call `.unscope()` on a `VaultView` which returns a reference to the original vault, allowing you to chain processing flows which dive in and out of different filtered scopes.
### `vault.write()`
Write all vault documents back to disk.
### `vault.documents`
(Property) An array of all documents in this vault
### `vault.index`
(Property) A map of document slugs to the document itself
### `new MarkdownDocument(filePath, vault)`
A `MarkdownDocument` object represents a single document in the `Vault`. Generally, the `Vault` itself handles creating these objects, which can be accessed via the `.documents` or `.index` properties on the `Vault`.
### `markdownDocument.setMarkdown(markdownContent)`
Set the markdown content of this document (separately from the YAML frontmatter)
### `markdownDocument.setFrontmatter(frontmatterShape)`
Set the frontmatter content of this document (separately from the markdown)
### `markdownDocument.setContent(newContent)`
Set the full text content of this document (raw YAML frontmatter and markdown)
### `markdownDocument.hasTag(tag)`
Check if this document is tagged with the given tag
### `markdownDocument.hasTaxonomy(dirs)`
Check if this document exists in the given directory structure as an array of directory names
### `markdownDocument.revert()`
Revert any changes to this document back to its original loaded content
### `markdownDocument.write()`
Write this file back to disk
### `markdownDocument.markdown`
(Property) The markdown contents of this document
### `markdownDocument.frontmatter`
(Property) The frontmatter contents of this document
### `markdownDocument.content`
(Property) The full content of this document

6
package-lock.json generated
View file

@ -1,13 +1,13 @@
{ {
"name": "@endeavorance/hammerstone", "name": "@endeavorance/hammerstone",
"version": "0.0.1", "version": "0.0.4",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "@endeavorance/hammerstone", "name": "@endeavorance/hammerstone",
"version": "0.0.1", "version": "0.0.4",
"license": "ISC", "license": "CC BY-NC-SA 4.0",
"dependencies": { "dependencies": {
"glob": "^10.3.12", "glob": "^10.3.12",
"lodash-es": "^4.17.21", "lodash-es": "^4.17.21",

View file

@ -1,6 +1,6 @@
{ {
"name": "@endeavorance/hammerstone", "name": "@endeavorance/hammerstone",
"version": "0.0.4", "version": "0.1.0",
"description": "Load and manipulate Obsidian vault data", "description": "Load and manipulate Obsidian vault data",
"type": "module", "type": "module",
"exports": "./bin/hammerstone.js", "exports": "./bin/hammerstone.js",

View file

@ -0,0 +1,93 @@
import test, { describe } from "node:test";
import assert from "node:assert";
import MarkdownDocument from "../document.js";
import Vault from "../vault.js";
function getTestDocument(): MarkdownDocument {
const vault = new Vault("./bin/_test/test-vault");
if (!vault.slugs.includes("testdoc")) {
assert.fail("Could not find test document");
}
return vault.index["testdoc"]!;
}
describe("MarkdownDocument", () => {
test("markdown and frontmatter are parsed out", () => {
const doc = getTestDocument();
assert.deepStrictEqual(doc.frontmatter, {
property: "value",
cssclasses: [],
tags: [],
aliases: [],
});
assert.equal(
doc.markdown.trim(),
"This is a test doc for use with testing docs.",
);
});
test("changing the frontmatter updates the full document contents", () => {
const doc = getTestDocument();
const originalContents = doc.content;
doc.setFrontmatter({
...doc.frontmatter,
example: "hello",
});
assert.notEqual(doc.content, originalContents);
});
test("changing the markdown updates the full document contents", () => {
const doc = getTestDocument();
const originalContents = doc.content;
doc.setMarkdown("Hello, worlt.");
assert.notEqual(doc.content, originalContents);
});
test("setting the full content updates markdown and frontmatter", () => {
const doc = getTestDocument();
doc.setContent(`---
hello: world
---
Testing!
`);
assert.deepStrictEqual(doc.frontmatter, {
hello: "world",
tags: [],
aliases: [],
cssclasses: [],
});
assert.equal(doc.markdown.trim(), "Testing!");
});
test("reverting works", () => {
const doc = getTestDocument();
const originalContents = doc.content;
doc.setFrontmatter({
...doc.frontmatter,
example: "hello",
});
doc.revert();
assert.equal(doc.content, originalContents);
});
test("checking for tags", () => {
const doc = getTestDocument();
const taggedDoc = doc.vault.index["directory-subdirectory-i-have-tags"]!;
assert.equal(taggedDoc.hasTag("tags"), true);
assert.equal(doc.hasTag("tags"), false);
});
test("checking for taxonomy", () => {
const doc = getTestDocument();
const taggedDoc = doc.vault.index["directory-subdirectory-i-have-tags"]!;
assert.equal(taggedDoc.hasTaxonomy(["Directory", "Subdirectory"]), true);
assert.equal(doc.hasTaxonomy(["Directory", "Subdirectory"]), false);
});
});

View file

@ -1,29 +0,0 @@
import test, { describe } from "node:test";
import assert from "node:assert";
import Vault from "../vault.js";
describe("Vault", () => {
test("load a vault", () => {
const vault = new Vault("./bin/_test/test-vault");
assert.equal(vault.size, 4, "Unexpected number of documents in vault");
});
test("ignore paths", () => {
const vault = new Vault("./bin/_test/test-vault", {
ignorePatterns: ["**/Ignoreme/**"],
});
assert.equal(vault.size, 3, "Unexpected number of documents in vault");
});
test("reading tags", () => {
const vault = new Vault("./bin/_test/test-vault");
const taggedFile = vault.index["directory-subdirectory-i-have-tags"];
if (taggedFile === undefined) {
assert.fail("Expected file with tags");
}
const expectedTags = ["propertytag", "tags", "hashtags"];
assert.deepStrictEqual(taggedFile.tags, expectedTags);
});
});

View file

@ -4,7 +4,10 @@ import process from "node:process";
import path from "node:path"; import path from "node:path";
run({ run({
files: [path.resolve("./bin/_test/main.test.js")], files: [
path.resolve("./bin/_test/vault.test.js"),
path.resolve("./bin/_test/document.test.js"),
],
}) })
.on("test:fail", () => { .on("test:fail", () => {
process.exitCode = 1; process.exitCode = 1;

View file

@ -0,0 +1,5 @@
---
property: value
---
This is a test doc for use with testing docs.

65
src/_test/vault.test.ts Normal file
View file

@ -0,0 +1,65 @@
import test, { describe } from "node:test";
import assert from "node:assert";
import Vault from "../vault.js";
import path from "node:path";
const DOCS_IN_TEST_VAULT = 5;
const EXCLUDED_DOCS = 1;
describe("Vault", () => {
test("load a vault", () => {
const vault = new Vault("./bin/_test/test-vault");
assert.equal(
vault.size,
DOCS_IN_TEST_VAULT,
"Unexpected number of documents in vault",
);
});
test("vault paths are made absolute", () => {
const vault = new Vault("./bin/_test/test-vault");
assert.equal(
path.isAbsolute(vault.vaultPath),
true,
"Vault path was not formatted to be absolute",
);
});
test("ignored paths are not loaded into the vault", () => {
const vault = new Vault("./bin/_test/test-vault", {
ignorePatterns: ["**/Ignoreme/**"],
});
assert.equal(
vault.size,
DOCS_IN_TEST_VAULT - EXCLUDED_DOCS,
"Unexpected number of documents in vault",
);
});
test("document tags are properly parsed", () => {
const vault = new Vault("./bin/_test/test-vault");
const taggedFile = vault.index["directory-subdirectory-i-have-tags"];
if (taggedFile === undefined) {
assert.fail("Expected file with tags");
}
const expectedTags = ["propertytag", "tags", "hashtags"];
assert.deepStrictEqual(taggedFile.tags, expectedTags);
});
test("views can generate filtered versions of a vault", () => {
const vault = new Vault("./bin/_test/test-vault");
const view = vault.scope((doc) => doc.hasTag("tags"));
assert.equal(view.size, 1);
});
test("processing documents", () => {
const vault = new Vault("./bin/_test/test-vault");
vault.process((doc) => doc.setMarkdown(""));
for (const doc of vault.documents) {
assert.equal(doc.markdown, "");
}
});
});

View file

@ -26,6 +26,9 @@ export default class MarkdownDocument {
/** The name of this document's file, without the file extension */ /** The name of this document's file, without the file extension */
readonly filename: string; 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 */ /** A url-safe version of this file's name */
readonly slug: string; readonly slug: string;
@ -33,7 +36,7 @@ export default class MarkdownDocument {
private _content: string; private _content: string;
/** The history of this files contents after each modification */ /** The history of this files contents after each modification */
readonly contentHistory: string[] = []; readonly contentHistory: string[];
/** The parsed frontmatter of the file */ /** The parsed frontmatter of the file */
private _frontmatter: FrontmatterShape; private _frontmatter: FrontmatterShape;
@ -44,8 +47,6 @@ export default class MarkdownDocument {
/** The markdown portion of the file (without frontmatter) */ /** The markdown portion of the file (without frontmatter) */
private _markdown: string; private _markdown: string;
tags: string[];
constructor(filePath: string, vault: Vault) { constructor(filePath: string, vault: Vault) {
if (!existsSync(filePath)) { if (!existsSync(filePath)) {
throw new Error(`File not found: ${filePath}`); throw new Error(`File not found: ${filePath}`);
@ -67,12 +68,14 @@ export default class MarkdownDocument {
this._markdown = extractMarkdown(rawFileContent); this._markdown = extractMarkdown(rawFileContent);
this.vault = vault; this.vault = vault;
// Identify tags from content and frontmatter
const frontmatterTags = frontmatter.tags; const frontmatterTags = frontmatter.tags;
const contentTags = extractTags(this._markdown); const contentTags = extractTags(this._markdown);
this.tags = frontmatterTags.concat(contentTags); this.tags = frontmatterTags.concat(contentTags);
} }
/**
* @returns A serializable shape for this object
*/
toJSON() { toJSON() {
return { return {
path: this.path, path: this.path,
@ -85,47 +88,51 @@ export default class MarkdownDocument {
}; };
} }
set markdown(newValue: string) { /**
* Update the markdown content for the document
* @param newValue The new markdown content for the document
*/
setMarkdown(newValue: string) {
this._markdown = newValue; this._markdown = newValue;
this._content = combineMarkdownAndFrontmatter(newValue, this.frontmatter); this._content = combineMarkdownAndFrontmatter(newValue, this.frontmatter);
this.contentHistory.push(this._content); this.contentHistory.push(this._content);
} }
/** The markdown portion of the file (without frontmatter) */ /**
get markdown() { * Update the frontmatter of the document
return this._markdown; * @param newValue The new frontmatter shape for this document
} */
setFrontmatter(newValue: FrontmatterShape) {
set frontmatter(newValue: FrontmatterShape) {
this._frontmatter = newValue; this._frontmatter = newValue;
this._content = combineMarkdownAndFrontmatter( this._content = combineMarkdownAndFrontmatter(this._markdown, newValue);
this._markdown,
this._frontmatter,
);
this.contentHistory.push(this._content); this.contentHistory.push(this._content);
} }
/** The parsed frontmatter of the file */ /**
get frontmatter() { * Update the full content of the doc with a combination of markdown and yaml frontmatter
return this._frontmatter; * @param newValue The full content of the document to set
} */
setContent(newValue: string) {
/** The raw content of the file */
get content() {
return this._content;
}
set content(newValue: string) {
this._content = newValue; this._content = newValue;
this._frontmatter = extractFrontmatter(newValue); this._frontmatter = extractFrontmatter(newValue);
this._markdown = extractMarkdown(newValue); this._markdown = extractMarkdown(newValue);
this.contentHistory.push(this._content); this.contentHistory.push(this._content);
} }
/**
* 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) { hasTag(tag: string) {
return this.tags.includes(tag); 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[]) { hasTaxonomy(dirs: string[]) {
if (dirs.length < this.taxonomy.length) { if (dirs.length < this.taxonomy.length) {
return false; return false;
@ -140,6 +147,22 @@ export default class MarkdownDocument {
return true; return true;
} }
/**
* Revert this document to its original loaded content
*/
revert() {
const originalCopy = this.contentHistory[0];
if (originalCopy === undefined) {
throw new Error("Document has no history to revert to.");
}
this.setContent(originalCopy);
}
/**
* Write this document back to disk
*/
write() { write() {
writeFileSync( writeFileSync(
this.path, this.path,
@ -147,4 +170,19 @@ export default class MarkdownDocument {
"utf-8", "utf-8",
); );
} }
/** 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;
}
} }

View file

@ -14,6 +14,7 @@ const EMPTY_FRONTMATTER: FrontmatterShape = {
}; };
const FRONTMATTER_REGEX = /^---[\s\S]*?---/gm; const FRONTMATTER_REGEX = /^---[\s\S]*?---/gm;
const TAG_REGEX = /#[A-Za-z0-9/\-_]+/g;
/** /**
* Attempt to parse YAML frontmatter from a mixed yaml/md doc * Attempt to parse YAML frontmatter from a mixed yaml/md doc
@ -49,20 +50,44 @@ export function extractMarkdown(content: string): string {
return content.replace(FRONTMATTER_REGEX, "").trim(); return content.replace(FRONTMATTER_REGEX, "").trim();
} }
/**
* Combine yaml frontmatter and markdown into a single string
* @param markdown The markdown to combine with frontmatter
* @param frontmatter The frontmatter shape to combine with markdown
* @returns A combined document with yaml frontmatter and markdown below
*/
export function combineMarkdownAndFrontmatter( export function combineMarkdownAndFrontmatter(
markdown: string, markdown: string,
frontmatter: FrontmatterShape, frontmatter: FrontmatterShape,
) { ) {
const frontmatterToWrite: Partial<FrontmatterShape> =
structuredClone(frontmatter);
if (frontmatterToWrite.aliases?.length === 0) {
delete frontmatterToWrite.aliases;
}
if (frontmatterToWrite.cssclasses?.length === 0) {
delete frontmatterToWrite.cssclasses;
}
if (frontmatterToWrite.tags?.length === 0) {
delete frontmatterToWrite.tags;
}
return `--- return `---
${YAML.stringify(frontmatter)} ${YAML.stringify(frontmatterToWrite)}
--- ---
${markdown.trim()} ${markdown.trim()}
`; `;
} }
const TAG_REGEX = /#[A-Za-z0-9/\-_]+/g; /**
* Find hashtags in text content
* @param content The content to search for tags in
* @returns An array of discovered tags (hash excluded)
*/
export function extractTags(content: string): string[] { export function extractTags(content: string): string[] {
const results = content.match(TAG_REGEX); const results = content.match(TAG_REGEX);

View file

@ -48,7 +48,7 @@ function buildVaultIndex(
return index; return index;
} }
export class VaultView { class VaultView {
/** An array of all discovered Markdown documents */ /** An array of all discovered Markdown documents */
documents: MarkdownDocument[] = []; documents: MarkdownDocument[] = [];
@ -72,10 +72,23 @@ export class VaultView {
this.size = documents.length; this.size = documents.length;
} }
map(fn: (document: MarkdownDocument) => MarkdownDocument) { /**
this.documents = this.documents.map(fn); * Map over each document in this view, modifying it as needed
* @param fn A function to map over every document in this view
* @returns A reference to this view to chain additional calls
*/
process(fn: (document: MarkdownDocument) => void) {
this.documents.forEach(fn);
return this; return this;
} }
/**
* Utility function to chain function calls back to the original vault
* @returns A reference to the vault that produced this view
*/
unscope(): Vault {
return this.vault;
}
} }
interface VaultOptions { interface VaultOptions {
@ -114,13 +127,18 @@ export default class Vault {
this.index = buildVaultIndex(allFiles); this.index = buildVaultIndex(allFiles);
} }
view(fn: (document: MarkdownDocument) => boolean) { scope(fn: (document: MarkdownDocument) => boolean) {
const matchingDocs = this.documents.filter(fn); const matchingDocs = this.documents.filter(fn);
return new VaultView(matchingDocs, this); return new VaultView(matchingDocs, this);
} }
map(fn: (document: MarkdownDocument) => MarkdownDocument) { /**
this.documents = this.documents.map(fn); * Map over each document in this vault, modifying it as needed
* @param fn A function to map over every document in this vault
* @returns A reference to this vault to chain additional calls
*/
process(fn: (document: MarkdownDocument) => void) {
this.documents.forEach(fn);
return this; return this;
} }