Changes
7 changed files (+377/-27)
-
-
@@ -12,8 +12,10 @@ - [ ] MathJax ... No support if it can't generate MathML at build time.- [ ] Syntax Highlighting - [ ] Obsidian Extensions - [ ] Internal Link path resolution - [ ] Absolute path in vault - [x] Absolute path in vault - [x] Absolute path in vault (extension-less) - [x] Relative path to file - [x] Relative path to file (extension-less) - [ ] Shortest path when possible - [ ] Wikilink - [ ] Label
-
-
-
@@ -0,0 +1,54 @@## Supported path resolutions ### Relative Relative link is the best format, especially if you consider compatibility to other tools. ```markdown [Overview](../Overview.md) ``` [Overview](../Overview.md) #### Without file extension Although you can omit file extensions in the file path, this is not recommended if you care compatibility or portability. This also affects performance. ```markdown [Overview](../Overview) ``` [Overview](../Overview) ### Absolute Absolute paths are resolved from the Vault root. ```markdown [Overview](/en/Overview.md) ``` [Overview](/en/Overview.md) #### Without file extension You can omit file extensions in the absolute form, too. This affects performance. ```markdown [Overview](/en/Overview) ``` [Overview](/en/Overview) ## Limitation ### Filename confusion on extension-less path When you have more than one file with same file stem (filename without extension part), you can't refer to the file without extension. For example, if you have both `Foo/Bar.md` and `Foo/Bar.jpg` on a same directory, below `Foo/Baz.md` results in a build error, because Macana cannot decide which file to use. ```markdown [Bar](./Bar) ```
-
-
-
@@ -112,31 +112,10 @@ throw new Error(`DenoFsReader: ${resolvedPath} is not a file`);} if (!path.length) { throw new Error(`DenoFsReader: path cannot be empty`); throw new Error(`DenoFsReader: file path cannot be empty`); } let parent: DirectoryReader | RootDirectoryReader = root; for (let i = 0, l = path.length - 1; i < l; i++) { const dir: DirectoryReader = { type: "directory", name: path[i], path: parent.type === "root" ? [path[i]] : [...parent.path, path[i]], parent, read: async () => { const converted: Array<FileReader | DirectoryReader> = []; for await (const entry of Deno.readDir(this.#resolve(path))) { converted.push(this.#fromDirEntry(entry, dir)); } return converted; }, }; parent = dir; } const parent = this.#constructParents(path, root); const file = path[path.length - 1];
-
@@ -150,8 +129,70 @@ return Deno.readFile(resolvedPath);}, }; }, openDirectory: (path) => { const resolvedPath = this.#resolve(path); const stat = Deno.statSync(resolvedPath); if (!stat.isDirectory) { throw new Error(``); } if (!path.length) { throw new Error(`DenoFsReader: directory path cannot be empty`); } const parent = this.#constructParents(path, root); const name = path[path.length - 1]; const dir: DirectoryReader = { type: "directory", name, path, parent, read: async () => { const converted: Array<FileReader | DirectoryReader> = []; for await (const entry of Deno.readDir(this.#resolve(path))) { converted.push(this.#fromDirEntry(entry, dir)); } return converted; }, }; return dir; }, }; return root; } #constructParents( path: readonly string[], root: RootDirectoryReader, ): DirectoryReader | RootDirectoryReader { let parent: DirectoryReader | RootDirectoryReader = root; for (let i = 0, l = path.length - 1; i < l; i++) { const dir: DirectoryReader = { type: "directory", name: path[i], path: parent.type === "root" ? [path[i]] : [...parent.path, path[i]], parent, read: async () => { const converted: Array<FileReader | DirectoryReader> = []; for await (const entry of Deno.readDir(this.#resolve(path))) { converted.push(this.#fromDirEntry(entry, dir)); } return converted; }, }; parent = dir; } return parent; } }
-
-
-
@@ -182,6 +182,15 @@ }return file; }, openDirectory: async (path) => { const dir = await this.#getAtRecur(path, root); if (dir.type === "file") { throw new Error(`MemoryFsReader: ${path.join(SEP)} is file`); } return dir; }, }; return Promise.resolve(root);
-
-
-
@@ -10,6 +10,7 @@ assertRejects,} from "../deps/deno.land/std/assert/mod.ts"; import { MemoryFsReader } from "../filesystem_reader/memory_fs.ts"; import type { ContentParser } from "../content_parser/interface.ts"; import { noopParser } from "../content_parser/noop.ts"; import { defaultDocumentAt,
-
@@ -20,8 +21,29 @@ ignoreDotfiles,langDir, removeExtFromMetadata, } from "./default_tree_builder.ts"; import type { DocumentToken } from "../types.ts"; const contentParser = noopParser; function linkCheckParser( opts: { fromPath: readonly string[]; toPath: readonly string[] }, ): ContentParser & { token: DocumentToken | null } { const parser: ContentParser & { token: DocumentToken | null } = { token: null, async parse({ fileReader, getDocumentToken }) { if (opts.fromPath.every((s, i) => s === fileReader.path[i])) { parser.token = await getDocumentToken(opts.toPath); } return { kind: "testing", content: null, }; }, }; return parser; } Deno.test("Should read from top-level directory, as-is", async () => { const fileSystemReader = new MemoryFsReader([
-
@@ -227,6 +249,126 @@ fileSystemReader,contentParser, }) ); }); Deno.test("Should resolve relative path link", async () => { const fileSystemReader = new MemoryFsReader([ { path: "Foo/Bar/Baz.md", content: "" }, { path: "Qux.md", content: "" }, ]); const builder = new DefaultTreeBuilder({ defaultLanguage: "en", strategies: [fileExtensions([".md"])], }); const contentParser = linkCheckParser({ fromPath: ["Foo", "Bar", "Baz.md"], toPath: ["..", "..", "Qux.md"], }); const tree = await builder.build({ fileSystemReader, contentParser, }); assertNotEquals(contentParser.token, null); // `as` is required as Deno's assertion function does not return `a is B`. const found = tree.exchangeToken(contentParser.token as DocumentToken); assertObjectMatch(found, { path: ["Qux.md"], }); }); Deno.test("Should resolve relative path link without file extension", async () => { const fileSystemReader = new MemoryFsReader([ { path: "Foo/Bar/Baz.md", content: "" }, { path: "Qux.md", content: "" }, ]); const builder = new DefaultTreeBuilder({ defaultLanguage: "en", strategies: [fileExtensions([".md"])], }); const contentParser = linkCheckParser({ fromPath: ["Foo", "Bar", "Baz.md"], toPath: ["..", "..", "Qux"], }); const tree = await builder.build({ fileSystemReader, contentParser, }); assertNotEquals(contentParser.token, null); // `as` is required as Deno's assertion function does not return `a is B`. const found = tree.exchangeToken(contentParser.token as DocumentToken); assertObjectMatch(found, { path: ["Qux.md"], }); }); Deno.test("Should resolve absolute path link", async () => { const fileSystemReader = new MemoryFsReader([ { path: "Foo/Bar/Baz.md", content: "" }, { path: "Qux.md", content: "" }, ]); const builder = new DefaultTreeBuilder({ defaultLanguage: "en", strategies: [fileExtensions([".md"])], }); const contentParser = linkCheckParser({ fromPath: ["Foo", "Bar", "Baz.md"], toPath: ["", "Qux.md"], }); const tree = await builder.build({ fileSystemReader, contentParser, }); assertNotEquals(contentParser.token, null); // `as` is required as Deno's assertion function does not return `a is B`. const found = tree.exchangeToken(contentParser.token as DocumentToken); assertObjectMatch(found, { path: ["Qux.md"], }); }); Deno.test("Should resolve absolute path link without file extension", async () => { const fileSystemReader = new MemoryFsReader([ { path: "Foo/Bar/Baz.md", content: "" }, { path: "Qux.md", content: "" }, ]); const builder = new DefaultTreeBuilder({ defaultLanguage: "en", strategies: [fileExtensions([".md"])], }); const contentParser = linkCheckParser({ fromPath: ["Foo", "Bar", "Baz.md"], toPath: ["", "Qux"], }); const tree = await builder.build({ fileSystemReader, contentParser, }); assertNotEquals(contentParser.token, null); // `as` is required as Deno's assertion function does not return `a is B`. const found = tree.exchangeToken(contentParser.token as DocumentToken); assertObjectMatch(found, { path: ["Qux.md"], }); }); Deno.test("ignore() and ignoreDotfiles() should ignore files and directories", async () => {
-
-
-
@@ -168,6 +168,11 @@ function resolveFsrPath(path: readonly string[], base: readonly string[], ): readonly string[] { // Absolute path if (path[0] === "") { return path.slice(1); } let buf: string[] = base.slice(0, -1); for (const fragment of path) {
-
@@ -184,6 +189,69 @@ }} return buf; } /** * @param root - Vault root directory. * @param path - Resolved path. */ function resolveExtensionLessPath( root: RootDirectoryReader, path: readonly string[], ): readonly string[] | Promise<readonly string[]> { const [filename, ...dirPathReversed] = path.toReversed(); if (!filename || filename.includes(".")) { return path; } const findClosestFile = async ( dir: DirectoryReader | RootDirectoryReader, ): Promise<readonly string[]> => { const entries = await dir.read(); const match = entries.filter((entry) => { if (entry.type !== "file") { return false; } const stem = entry.name.split(".").slice(0, -1).join("."); return stem === filename; }); if (match.length > 1) { // TODO: Custom error class throw new Error( "DefaultTreeBuilder: cannot resolve extension-less reference, " + "there is several files with same stem but different extensions: " + `requested = ${path.join(INTERNAL_PATH_SEPARATOR)}, ` + `found = [${match.map((entry) => entry.name).join(", ")}].`, ); } if (!match.length) { const dirPath = dirPathReversed.length > 0 ? dirPathReversed.toReversed().join( INTERNAL_PATH_SEPARATOR, ) : "Root directory "; throw new Error( "DefaultTreeBuilder: cannot resolve extension-less reference, " + `${dirPath} does not contain any files whose stem is "${filename}".`, ); } return match[0].path; }; const dir = !dirPathReversed.length ? root : root.openDirectory(dirPathReversed.toReversed()); if (dir instanceof Promise) { return dir.then(findClosestFile); } return findClosestFile(dir); } interface InternalBuildParameters {
-
@@ -347,23 +415,49 @@ const result = await contentParser.parse({fileReader: node, documentMetadata: metadata, async getAssetToken(path) { if (!path.length) { throw new Error( `Asset link cannot be empty (processing ${ node.path.join(INTERNAL_PATH_SEPARATOR) })`, ); } const id = crypto.randomUUID(); const token: AssetToken = `mxa_${id}`; const resolvedPath = await resolveExtensionLessPath( root, resolveFsrPath(path, node.path), ); assetTokensToFiles.set( token, await root.openFile(resolveFsrPath(path, node.path)), await root.openFile(resolvedPath), ); return token; }, getDocumentToken(path) { async getDocumentToken(path) { if (!path.length) { throw new Error( `Document link cannot be empty (processing ${ node.path.join(INTERNAL_PATH_SEPARATOR) })`, ); } const id = crypto.randomUUID(); const token: DocumentToken = `mxt_${id}`; const resolvedPath = await resolveExtensionLessPath( root, resolveFsrPath(path, node.path), ); documentTokenToPaths.set( token, resolveFsrPath(path, node.path).join(INTERNAL_PATH_SEPARATOR), resolvedPath.join(INTERNAL_PATH_SEPARATOR), ); return token;
-
-
-
@@ -34,6 +34,14 @@ * Returns a file at the path.* This function may throw an error if the file not found. */ openFile(path: readonly string[]): Promise<FileReader> | FileReader; /** * Returns a directory at the path. * This function may throw an error if the directory not found. */ openDirectory( path: readonly string[], ): Promise<DirectoryReader> | DirectoryReader; } export interface DocumentMetadata {
-