Begin adding tests

This commit is contained in:
Endeavorance 2024-05-08 15:37:54 -04:00
parent e089616deb
commit bc5ddf3c23
18 changed files with 447 additions and 131 deletions

1
.npmignore Normal file
View file

@ -0,0 +1 @@
bin/_test

View file

@ -9,7 +9,7 @@
"build": "npm run clean && tsc", "build": "npm run clean && tsc",
"clean": "shx rm -rf bin", "clean": "shx rm -rf bin",
"dev": "npm run build && npm start", "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": [], "keywords": [],
"author": "", "author": "",

29
src/_test/main.test.ts Normal file
View file

@ -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);
});
});

13
src/_test/test-runner.ts Normal file
View file

@ -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);

View file

@ -0,0 +1 @@
{}

View file

@ -0,0 +1,3 @@
{
"accentColor": ""
}

View file

@ -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
}

View file

@ -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"
]

View file

@ -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
}

View file

@ -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"
]
}

View file

@ -0,0 +1 @@
This is a cool cool note, it also links to the [[Welcome]] note.

View file

@ -0,0 +1,5 @@
---
tags:
- propertytag
---
I am a file that has some #tags in it, using inline #hashtags as well as the properties.

View file

@ -0,0 +1 @@
This file should not be read because it is not important.

View file

@ -0,0 +1 @@
This is the home document of this here test vault.

View file

@ -1,116 +1,14 @@
import { existsSync, readFileSync, writeFileSync } from "fs"; import { existsSync, readFileSync, writeFileSync } from "fs";
import path from "node:path"; import path from "node:path";
import slug from "slug"; import slug from "slug";
import YAML from "yaml";
import type Vault from "./vault.js"; import type Vault from "./vault.js";
import {
export type FrontmatterShape = Record<string, string | string[]>; combineMarkdownAndFrontmatter,
extractFrontmatter,
const FRONTMATTER_REGEX = /^---[\s\S]*?---/gm; extractMarkdown,
extractTags,
function getBlockTags(blockName: string): [string, string] { type FrontmatterShape,
return [`%% {${blockName}} %%`, `%% {/${blockName}} %%`]; } from "./markdown.js";
}
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 { export default class MarkdownDocument {
/** A reference to the vault containing this document */ /** A reference to the vault containing this document */
@ -119,12 +17,10 @@ export default class MarkdownDocument {
/** The original file path to this document */ /** The original file path to this document */
readonly path: string; 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 */ /** The directory/folder that this Document is in, relative to the vault root */
readonly dirname: string; readonly dirname: string;
/** An array of directory names this document is found under */
readonly taxonomy: string[]; readonly taxonomy: string[];
/** The name of this document's file, without the file extension */ /** 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) */ /** 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}`);
@ -156,30 +54,23 @@ export default class MarkdownDocument {
const rawFileContent = readFileSync(filePath, "utf-8"); const rawFileContent = readFileSync(filePath, "utf-8");
const fileDirname = path.dirname(filePath); const fileDirname = path.dirname(filePath);
const vaultPath = vault.vaultPath; const vaultPath = vault.vaultPath;
const frontmatter = extractFrontmatter(rawFileContent);
this.path = filePath; this.path = filePath;
this.dirname = path.relative(vaultPath, fileDirname); this.dirname = path.relative(vaultPath, fileDirname);
this.taxonomy = this.dirname.split(path.sep); this.taxonomy = this.dirname.split(path.sep);
this.vaultRootPath = vaultPath;
this.filename = path.basename(filePath, ".md"); this.filename = path.basename(filePath, ".md");
this.slug = slug(`${this.taxonomy.join("-")}-${this.filename}`); this.slug = slug(`${this.taxonomy.join("-")}-${this.filename}`);
this._content = rawFileContent; this._content = rawFileContent;
this.contentHistory = [rawFileContent]; this.contentHistory = [rawFileContent];
this._frontmatter = processDocumentFrontmatter(rawFileContent); this._frontmatter = frontmatter;
this._markdown = getDocumentMarkdown(rawFileContent); this._markdown = extractMarkdown(rawFileContent);
this.vault = vault; this.vault = vault;
}
containsBlock(blockName: string): boolean { // Identify tags from content and frontmatter
return documentContainsBlock(this, blockName); const frontmatterTags = frontmatter.tags;
} const contentTags = extractTags(this._markdown);
this.tags = frontmatterTags.concat(contentTags);
readBlock(blockName: string): string | undefined {
return readDocumentBlock(this, blockName);
}
setBlockContent(blockName: string, newContent: string): MarkdownDocument {
return writeDocumentBlock(this, blockName, newContent);
} }
toJSON() { toJSON() {
@ -226,11 +117,29 @@ export default class MarkdownDocument {
set content(newValue: string) { set content(newValue: string) {
this._content = newValue; this._content = newValue;
this._frontmatter = processDocumentFrontmatter(newValue); this._frontmatter = extractFrontmatter(newValue);
this._markdown = getDocumentMarkdown(newValue); this._markdown = extractMarkdown(newValue);
this.contentHistory.push(this._content); 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() { write() {
writeFileSync( writeFileSync(
this.path, this.path,

View file

@ -1,4 +1,5 @@
import Vault from "./vault.js"; 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 }; export { Vault, MarkdownDocument, FrontmatterShape };

74
src/markdown.ts Normal file
View file

@ -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("#", ""));
}

View file

@ -1,3 +1,4 @@
import path from "node:path";
import { globSync } from "glob"; import { globSync } from "glob";
import MarkdownDocument from "./document.js"; import MarkdownDocument from "./document.js";
@ -47,22 +48,61 @@ function buildVaultIndex(
return index; 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<string, MarkdownDocument> = {};
/** 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 { interface VaultOptions {
ignorePatterns?: string[]; ignorePatterns?: string[];
} }
export default class Vault { export default class Vault {
/** An array of all discovered Markdown documents */
documents: MarkdownDocument[] = []; documents: MarkdownDocument[] = [];
/** An array of all generated document slugs */
slugs: string[] = []; slugs: string[] = [];
/** A map of generated document slugs to their associated document */
index: Record<string, MarkdownDocument> = {}; index: Record<string, MarkdownDocument> = {};
/** The absolute path of this fault on disk */
readonly vaultPath: string; readonly vaultPath: string;
/** The number of documents in this vault */
readonly size: number = 0; readonly size: number = 0;
/** File patterns to ignore when discovering vault files */
private ignorePatterns: string[] = []; private ignorePatterns: string[] = [];
constructor(vaultRootPath: string, options?: VaultOptions) { constructor(vaultRootPath: string, options?: VaultOptions) {
this.vaultPath = vaultRootPath; this.vaultPath = path.resolve(vaultRootPath);
this.ignorePatterns = options?.ignorePatterns ?? []; this.ignorePatterns = options?.ignorePatterns ?? [];
const allFiles = loadVaultDocuments(this, this.ignorePatterns); const allFiles = loadVaultDocuments(this, this.ignorePatterns);
@ -74,6 +114,11 @@ export default class Vault {
this.index = buildVaultIndex(allFiles); 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) { map(fn: (document: MarkdownDocument) => MarkdownDocument) {
this.documents = this.documents.map(fn); this.documents = this.documents.map(fn);
return this; return this;