From bc5ddf3c23d1f173748277f9873040cdb1fab027 Mon Sep 17 00:00:00 2001 From: Endeavorance Date: Wed, 8 May 2024 15:37:54 -0400 Subject: [PATCH] Begin adding tests --- .npmignore | 1 + package.json | 2 +- src/_test/main.test.ts | 29 +++ src/_test/test-runner.ts | 13 ++ src/_test/test-vault/.obsidian/app.json | 1 + .../test-vault/.obsidian/appearance.json | 3 + .../.obsidian/core-plugins-migration.json | 30 ++++ .../test-vault/.obsidian/core-plugins.json | 20 +++ src/_test/test-vault/.obsidian/graph.json | 22 +++ src/_test/test-vault/.obsidian/workspace.json | 160 +++++++++++++++++ .../test-vault/Another Directory/Cool note.md | 1 + .../Directory/Subdirectory/I have tags.md | 5 + .../test-vault/Ignoreme/Unimportant file.md | 1 + src/_test/test-vault/Welcome.md | 1 + src/document.ts | 165 ++++-------------- src/hammerstone.ts | 3 +- src/markdown.ts | 74 ++++++++ src/vault.ts | 47 ++++- 18 files changed, 447 insertions(+), 131 deletions(-) create mode 100644 .npmignore create mode 100644 src/_test/main.test.ts create mode 100644 src/_test/test-runner.ts create mode 100644 src/_test/test-vault/.obsidian/app.json create mode 100644 src/_test/test-vault/.obsidian/appearance.json create mode 100644 src/_test/test-vault/.obsidian/core-plugins-migration.json create mode 100644 src/_test/test-vault/.obsidian/core-plugins.json create mode 100644 src/_test/test-vault/.obsidian/graph.json create mode 100644 src/_test/test-vault/.obsidian/workspace.json create mode 100644 src/_test/test-vault/Another Directory/Cool note.md create mode 100644 src/_test/test-vault/Directory/Subdirectory/I have tags.md create mode 100644 src/_test/test-vault/Ignoreme/Unimportant file.md create mode 100644 src/_test/test-vault/Welcome.md create mode 100644 src/markdown.ts diff --git a/.npmignore b/.npmignore new file mode 100644 index 0000000..1b8b9da --- /dev/null +++ b/.npmignore @@ -0,0 +1 @@ +bin/_test \ No newline at end of file diff --git a/package.json b/package.json index fad0e94..03fff8e 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ "build": "npm run clean && tsc", "clean": "shx rm -rf bin", "dev": "npm run build && npm start", - "start": "node ./bin/hammerstone.js" + "test": "npm run build && shx cp -r src/_test/test-vault bin/_test/test-vault && node ./bin/_test/test-runner.js" }, "keywords": [], "author": "", diff --git a/src/_test/main.test.ts b/src/_test/main.test.ts new file mode 100644 index 0000000..b92e104 --- /dev/null +++ b/src/_test/main.test.ts @@ -0,0 +1,29 @@ +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); + }); +}); diff --git a/src/_test/test-runner.ts b/src/_test/test-runner.ts new file mode 100644 index 0000000..0b13a11 --- /dev/null +++ b/src/_test/test-runner.ts @@ -0,0 +1,13 @@ +import { tap } from "node:test/reporters"; +import { run } from "node:test"; +import process from "node:process"; +import path from "node:path"; + +run({ + files: [path.resolve("./bin/_test/main.test.js")], +}) + .on("test:fail", () => { + process.exitCode = 1; + }) + .compose(tap) + .pipe(process.stdout); diff --git a/src/_test/test-vault/.obsidian/app.json b/src/_test/test-vault/.obsidian/app.json new file mode 100644 index 0000000..9e26dfe --- /dev/null +++ b/src/_test/test-vault/.obsidian/app.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/src/_test/test-vault/.obsidian/appearance.json b/src/_test/test-vault/.obsidian/appearance.json new file mode 100644 index 0000000..c8c365d --- /dev/null +++ b/src/_test/test-vault/.obsidian/appearance.json @@ -0,0 +1,3 @@ +{ + "accentColor": "" +} \ No newline at end of file diff --git a/src/_test/test-vault/.obsidian/core-plugins-migration.json b/src/_test/test-vault/.obsidian/core-plugins-migration.json new file mode 100644 index 0000000..436f43c --- /dev/null +++ b/src/_test/test-vault/.obsidian/core-plugins-migration.json @@ -0,0 +1,30 @@ +{ + "file-explorer": true, + "global-search": true, + "switcher": true, + "graph": true, + "backlink": true, + "canvas": true, + "outgoing-link": true, + "tag-pane": true, + "properties": false, + "page-preview": true, + "daily-notes": true, + "templates": true, + "note-composer": true, + "command-palette": true, + "slash-command": false, + "editor-status": true, + "bookmarks": true, + "markdown-importer": false, + "zk-prefixer": false, + "random-note": false, + "outline": true, + "word-count": true, + "slides": false, + "audio-recorder": false, + "workspaces": false, + "file-recovery": true, + "publish": false, + "sync": false +} \ No newline at end of file diff --git a/src/_test/test-vault/.obsidian/core-plugins.json b/src/_test/test-vault/.obsidian/core-plugins.json new file mode 100644 index 0000000..9405bfd --- /dev/null +++ b/src/_test/test-vault/.obsidian/core-plugins.json @@ -0,0 +1,20 @@ +[ + "file-explorer", + "global-search", + "switcher", + "graph", + "backlink", + "canvas", + "outgoing-link", + "tag-pane", + "page-preview", + "daily-notes", + "templates", + "note-composer", + "command-palette", + "editor-status", + "bookmarks", + "outline", + "word-count", + "file-recovery" +] \ No newline at end of file diff --git a/src/_test/test-vault/.obsidian/graph.json b/src/_test/test-vault/.obsidian/graph.json new file mode 100644 index 0000000..42a46ec --- /dev/null +++ b/src/_test/test-vault/.obsidian/graph.json @@ -0,0 +1,22 @@ +{ + "collapse-filter": true, + "search": "", + "showTags": false, + "showAttachments": false, + "hideUnresolved": false, + "showOrphans": true, + "collapse-color-groups": true, + "colorGroups": [], + "collapse-display": true, + "showArrow": false, + "textFadeMultiplier": 0, + "nodeSizeMultiplier": 1, + "lineSizeMultiplier": 1, + "collapse-forces": true, + "centerStrength": 0.518713248970312, + "repelStrength": 10, + "linkStrength": 1, + "linkDistance": 250, + "scale": 1, + "close": true +} \ No newline at end of file diff --git a/src/_test/test-vault/.obsidian/workspace.json b/src/_test/test-vault/.obsidian/workspace.json new file mode 100644 index 0000000..7260a49 --- /dev/null +++ b/src/_test/test-vault/.obsidian/workspace.json @@ -0,0 +1,160 @@ +{ + "main": { + "id": "0d432b44da4e24cf", + "type": "split", + "children": [ + { + "id": "6bba5a91ffd45e55", + "type": "tabs", + "children": [ + { + "id": "c548f27295fc9f89", + "type": "leaf", + "state": { + "type": "markdown", + "state": { + "file": "Ignoreme/Unimportant file.md", + "mode": "source", + "source": false + } + } + } + ] + } + ], + "direction": "vertical" + }, + "left": { + "id": "eb8f617338bff33a", + "type": "split", + "children": [ + { + "id": "e9168ba4d39b9811", + "type": "tabs", + "children": [ + { + "id": "0714e101e8d2e95b", + "type": "leaf", + "state": { + "type": "file-explorer", + "state": { + "sortOrder": "alphabetical" + } + } + }, + { + "id": "d8fde6378b886f78", + "type": "leaf", + "state": { + "type": "search", + "state": { + "query": "", + "matchingCase": false, + "explainSearch": false, + "collapseAll": false, + "extraContext": false, + "sortOrder": "alphabetical" + } + } + }, + { + "id": "2f84c2d6a1864258", + "type": "leaf", + "state": { + "type": "bookmarks", + "state": {} + } + } + ] + } + ], + "direction": "horizontal", + "width": 300 + }, + "right": { + "id": "5dc6da21227cd234", + "type": "split", + "children": [ + { + "id": "7ce4d629d6f2274c", + "type": "tabs", + "children": [ + { + "id": "996e1a5e0748190e", + "type": "leaf", + "state": { + "type": "backlink", + "state": { + "file": "Ignoreme/Unimportant file.md", + "collapseAll": false, + "extraContext": false, + "sortOrder": "alphabetical", + "showSearch": false, + "searchQuery": "", + "backlinkCollapsed": false, + "unlinkedCollapsed": true + } + } + }, + { + "id": "aaed9afdd72608ad", + "type": "leaf", + "state": { + "type": "outgoing-link", + "state": { + "file": "Ignoreme/Unimportant file.md", + "linksCollapsed": false, + "unlinkedCollapsed": true + } + } + }, + { + "id": "71cf6badf7dcaf51", + "type": "leaf", + "state": { + "type": "tag", + "state": { + "sortOrder": "frequency", + "useHierarchy": true + } + } + }, + { + "id": "93936f2b9e8b5004", + "type": "leaf", + "state": { + "type": "outline", + "state": { + "file": "Ignoreme/Unimportant file.md" + } + } + } + ] + } + ], + "direction": "horizontal", + "width": 300, + "collapsed": true + }, + "left-ribbon": { + "hiddenItems": { + "switcher:Open quick switcher": false, + "graph:Open graph view": false, + "canvas:Create new canvas": false, + "daily-notes:Open today's daily note": false, + "templates:Insert template": false, + "command-palette:Open command palette": false + } + }, + "active": "c548f27295fc9f89", + "lastOpenFiles": [ + "Another Directory/Cool note.md", + "Ignoreme/Unimportant file.md", + "Ignoreme", + "Directory/Subdirectory/I have tags.md", + "Another Directory", + "Welcome.md", + "Directory/Subdirectory", + "Directory" + ] +} \ No newline at end of file diff --git a/src/_test/test-vault/Another Directory/Cool note.md b/src/_test/test-vault/Another Directory/Cool note.md new file mode 100644 index 0000000..25fb06f --- /dev/null +++ b/src/_test/test-vault/Another Directory/Cool note.md @@ -0,0 +1 @@ +This is a cool cool note, it also links to the [[Welcome]] note. \ No newline at end of file diff --git a/src/_test/test-vault/Directory/Subdirectory/I have tags.md b/src/_test/test-vault/Directory/Subdirectory/I have tags.md new file mode 100644 index 0000000..594e915 --- /dev/null +++ b/src/_test/test-vault/Directory/Subdirectory/I have tags.md @@ -0,0 +1,5 @@ +--- +tags: + - propertytag +--- +I am a file that has some #tags in it, using inline #hashtags as well as the properties. \ No newline at end of file diff --git a/src/_test/test-vault/Ignoreme/Unimportant file.md b/src/_test/test-vault/Ignoreme/Unimportant file.md new file mode 100644 index 0000000..a0a3a57 --- /dev/null +++ b/src/_test/test-vault/Ignoreme/Unimportant file.md @@ -0,0 +1 @@ +This file should not be read because it is not important. \ No newline at end of file diff --git a/src/_test/test-vault/Welcome.md b/src/_test/test-vault/Welcome.md new file mode 100644 index 0000000..adcfe43 --- /dev/null +++ b/src/_test/test-vault/Welcome.md @@ -0,0 +1 @@ +This is the home document of this here test vault. \ No newline at end of file diff --git a/src/document.ts b/src/document.ts index 0e0aac3..a13a3a1 100644 --- a/src/document.ts +++ b/src/document.ts @@ -1,116 +1,14 @@ import { existsSync, readFileSync, writeFileSync } from "fs"; import path from "node:path"; import slug from "slug"; -import YAML from "yaml"; import type Vault from "./vault.js"; - -export type FrontmatterShape = Record; - -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()} -`; -} +import { + combineMarkdownAndFrontmatter, + extractFrontmatter, + extractMarkdown, + extractTags, + type FrontmatterShape, +} from "./markdown.js"; export default class MarkdownDocument { /** A reference to the vault containing this document */ @@ -119,12 +17,10 @@ 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; + /** An array of directory names this document is found under */ readonly taxonomy: string[]; /** The name of this document's file, without the file extension */ @@ -148,6 +44,8 @@ export default class MarkdownDocument { /** The markdown portion of the file (without frontmatter) */ private _markdown: string; + tags: string[]; + constructor(filePath: string, vault: Vault) { if (!existsSync(filePath)) { throw new Error(`File not found: ${filePath}`); @@ -156,30 +54,23 @@ export default class MarkdownDocument { 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.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); + this._frontmatter = frontmatter; + this._markdown = extractMarkdown(rawFileContent); this.vault = vault; - } - 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); + // Identify tags from content and frontmatter + const frontmatterTags = frontmatter.tags; + const contentTags = extractTags(this._markdown); + this.tags = frontmatterTags.concat(contentTags); } toJSON() { @@ -226,11 +117,29 @@ export default class MarkdownDocument { set content(newValue: string) { this._content = newValue; - this._frontmatter = processDocumentFrontmatter(newValue); - this._markdown = getDocumentMarkdown(newValue); + this._frontmatter = extractFrontmatter(newValue); + this._markdown = extractMarkdown(newValue); this.contentHistory.push(this._content); } + hasTag(tag: string) { + return this.tags.includes(tag); + } + + 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; + } + write() { writeFileSync( this.path, diff --git a/src/hammerstone.ts b/src/hammerstone.ts index 9043964..c71ca0b 100644 --- a/src/hammerstone.ts +++ b/src/hammerstone.ts @@ -1,4 +1,5 @@ import Vault from "./vault.js"; -import MarkdownDocument, { type FrontmatterShape } from "./document.js"; +import MarkdownDocument from "./document.js"; +import type { FrontmatterShape } from "./markdown.js"; export { Vault, MarkdownDocument, FrontmatterShape }; diff --git a/src/markdown.ts b/src/markdown.ts new file mode 100644 index 0000000..2073b3f --- /dev/null +++ b/src/markdown.ts @@ -0,0 +1,74 @@ +import YAML from "yaml"; + +export interface FrontmatterShape { + tags: string[]; + aliases: string[]; + cssclasses: string[]; + [key: string]: string | string[]; +} + +const EMPTY_FRONTMATTER: FrontmatterShape = { + tags: [], + aliases: [], + cssclasses: [], +}; + +const FRONTMATTER_REGEX = /^---[\s\S]*?---/gm; + +/** + * Attempt to parse YAML frontmatter from a mixed yaml/md doc + * @param content The raw markdown content + * @returns Any successfully parsed frontmatter + */ +export function extractFrontmatter(content: string): FrontmatterShape { + // If it does not start with `---`, it is invalid for frontmatter + if (content.trim().indexOf("---") !== 0) { + return { + ...EMPTY_FRONTMATTER, + }; + } + + let frontmatterData = {}; + + if (FRONTMATTER_REGEX.test(content)) { + const frontmatterString = content.match(FRONTMATTER_REGEX)![0]; + const cleanFrontmatter = frontmatterString.replaceAll("---", "").trim(); + frontmatterData = YAML.parse(cleanFrontmatter); + } + + return { + ...EMPTY_FRONTMATTER, + ...frontmatterData, + }; +} + +export function extractMarkdown(content: string): string { + if (content.trim().indexOf("---") !== 0) { + return content; + } + return content.replace(FRONTMATTER_REGEX, "").trim(); +} + +export function combineMarkdownAndFrontmatter( + markdown: string, + frontmatter: FrontmatterShape, +) { + return `--- +${YAML.stringify(frontmatter)} +--- + +${markdown.trim()} +`; +} + +const TAG_REGEX = /#[A-Za-z0-9/\-_]+/g; + +export function extractTags(content: string): string[] { + const results = content.match(TAG_REGEX); + + if (results === null) { + return []; + } + + return results.map((result) => result.replace("#", "")); +} diff --git a/src/vault.ts b/src/vault.ts index f756743..c8bca16 100644 --- a/src/vault.ts +++ b/src/vault.ts @@ -1,3 +1,4 @@ +import path from "node:path"; import { globSync } from "glob"; import MarkdownDocument from "./document.js"; @@ -47,22 +48,61 @@ function buildVaultIndex( return index; } +export class VaultView { + /** An array of all discovered Markdown documents */ + documents: MarkdownDocument[] = []; + + /** An array of all generated document slugs */ + slugs: string[] = []; + + /** A map of generated document slugs to their associated document */ + index: Record = {}; + + /** The absolute path of this fault on disk */ + readonly vault: Vault; + + /** The number of documents in this vault */ + readonly size: number = 0; + + constructor(documents: MarkdownDocument[], vault: Vault) { + this.documents = documents; + this.slugs = documents.map((doc) => doc.slug); + this.index = buildVaultIndex(documents); + this.vault = vault; + this.size = documents.length; + } + + map(fn: (document: MarkdownDocument) => MarkdownDocument) { + this.documents = this.documents.map(fn); + return this; + } +} + interface VaultOptions { ignorePatterns?: string[]; } export default class Vault { + /** An array of all discovered Markdown documents */ documents: MarkdownDocument[] = []; + + /** An array of all generated document slugs */ slugs: string[] = []; + + /** A map of generated document slugs to their associated document */ index: Record = {}; + + /** The absolute path of this fault on disk */ readonly vaultPath: string; + /** The number of documents in this vault */ readonly size: number = 0; + /** File patterns to ignore when discovering vault files */ private ignorePatterns: string[] = []; constructor(vaultRootPath: string, options?: VaultOptions) { - this.vaultPath = vaultRootPath; + this.vaultPath = path.resolve(vaultRootPath); this.ignorePatterns = options?.ignorePatterns ?? []; const allFiles = loadVaultDocuments(this, this.ignorePatterns); @@ -74,6 +114,11 @@ export default class Vault { this.index = buildVaultIndex(allFiles); } + view(fn: (document: MarkdownDocument) => boolean) { + const matchingDocs = this.documents.filter(fn); + return new VaultView(matchingDocs, this); + } + map(fn: (document: MarkdownDocument) => MarkdownDocument) { this.documents = this.documents.map(fn); return this;