yanki 2.0.11 → 2.0.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/lib/index.js CHANGED
@@ -509,7 +509,7 @@ function getFileExtensionForMimeType(mimeType) {
509
509
  //#endregion
510
510
  //#region src/lib/utilities/path.ts
511
511
  const WINDOWS_DRIVE_LETTER_REGEX = /* @__PURE__ */ new RegExp("^[A-Z]:", "iv");
512
- const QUERY_FRAGMENT_START_REGEX = /* @__PURE__ */ new RegExp("[#?^]", "v");
512
+ const QUERY_FRAGMENT_START_REGEX = /* @__PURE__ */ new RegExp("[#^]", "v");
513
513
  /**
514
514
  * The browserify polyfill doesn't implement win32 absolute path detection...
515
515
  *
@@ -575,26 +575,36 @@ function getBaseAndQueryParts(filePath) {
575
575
  const [base, query] = splitAtFirstMatch(path.basename(filePath), QUERY_FRAGMENT_START_REGEX);
576
576
  return [path.join(directoryPath, base), query];
577
577
  }
578
+ /**
579
+ * Get every plausible base and anchor interpretation of a local link target
580
+ * that may contain anchor delimiter characters (`#`, `^`), ordered from the
581
+ * longest literal path (no anchor at all) to the shortest (anchor starts at the
582
+ * first delimiter, matching Obsidian's anchor syntax).
583
+ *
584
+ * This operates on the unnormalized link target and deliberately considers
585
+ * delimiters before slashes. A slash after a delimiter belongs to the anchor,
586
+ * not the file path. File and directory names may also legitimately contain
587
+ * these characters, so callers must normalize only each candidate's `filePath`
588
+ * and try candidates in order against a list of real files.
589
+ * https://github.com/kitschpatrol/yanki/issues/20
590
+ */
591
+ function getLocalPathCandidates(linkTarget) {
592
+ const candidates = [{
593
+ anchor: void 0,
594
+ filePath: linkTarget
595
+ }];
596
+ for (let index = linkTarget.length - 1; index >= 0; index--) if (QUERY_FRAGMENT_START_REGEX.test(linkTarget.charAt(index))) candidates.push({
597
+ anchor: linkTarget.slice(index),
598
+ filePath: linkTarget.slice(0, index)
599
+ });
600
+ return candidates;
601
+ }
578
602
  function getBase(filePath) {
579
603
  return getBaseAndQueryParts(filePath)[0];
580
604
  }
581
605
  function getQuery(filePath) {
582
606
  return getBaseAndQueryParts(filePath).at(1) ?? "";
583
607
  }
584
- function hasExtension(filePath) {
585
- return getExtension(filePath) !== "";
586
- }
587
- function getExtension(filePath) {
588
- return path.extname(getBase(filePath));
589
- }
590
- function addExtensionIfMissing(filePath, extension) {
591
- if (hasExtension(filePath)) return filePath;
592
- return addExtension(filePath, extension);
593
- }
594
- function addExtension(filePath, extension) {
595
- const [base, query] = getBaseAndQueryParts(filePath);
596
- return `${base}.${extension}${query ?? ""}`;
597
- }
598
608
  //#endregion
599
609
  //#region src/lib/utilities/url.ts
600
610
  const DRIVE_LETTER_REGEX = /* @__PURE__ */ new RegExp("^[a-z]:", "iv");
@@ -1792,7 +1802,9 @@ const defaultResolveLinkOptions = {
1792
1802
  * @returns Resolved absolute path or URL One of:
1793
1803
  *
1794
1804
  * - Resolved absolute POSIX-style paths
1795
- * - Removes any file path query parameters
1805
+ * - Removes any Obsidian-style heading and block anchors, unless the anchor
1806
+ * characters are a literal part of a matched file's name (`?` is always
1807
+ * treated as a literal file name character in local paths)
1796
1808
  * - Not URI-encoded
1797
1809
  * - Retains original case
1798
1810
  * - HTTP protocol URL
@@ -1800,49 +1812,25 @@ const defaultResolveLinkOptions = {
1800
1812
  * parameters)
1801
1813
  */
1802
1814
  function resolveLink(filePathOrUrl, options) {
1803
- const { allFilePaths, basePath, convertFilePathsToProtocol, cwd, obsidianVaultName, type } = deepmerge(defaultResolveLinkOptions, options ?? {});
1815
+ const resolvedOptions = deepmerge(defaultResolveLinkOptions, options ?? {});
1816
+ const { allFilePaths, convertFilePathsToProtocol, cwd, obsidianVaultName } = resolvedOptions;
1804
1817
  if (convertFilePathsToProtocol === "obsidian" && obsidianVaultName === void 0) console.warn(`convertFilePathsToProtocol is 'obsidian', but no obsidianVaultName provided`);
1805
- let currentPathOrUrl = filePathOrUrl;
1806
- for (;;) {
1807
- const decodedUrl = safeDecodeURI(currentPathOrUrl) ?? currentPathOrUrl;
1808
- const sourceType = getSrcType(decodedUrl);
1809
- if (sourceType === "localFileName") {
1810
- let resolvedUrl = addExtensionIfMissing(normalize(decodedUrl), "md");
1811
- resolvedUrl = resolveNameLink(resolvedUrl, cwd, allFilePaths ?? []) ?? resolveWithBasePath(decodedUrl, {
1812
- basePath,
1813
- cwd
1814
- });
1815
- if (getSrcType(resolvedUrl) === "localFilePath") {
1816
- currentPathOrUrl = resolvedUrl;
1817
- continue;
1818
- }
1819
- console.warn(`Failed to convert local file wiki-style name to path: ${currentPathOrUrl} --> ${resolvedUrl}`);
1820
- return resolvedUrl;
1818
+ const decodedUrl = safeDecodeURI(filePathOrUrl) ?? filePathOrUrl;
1819
+ switch (getSrcType(decodedUrl)) {
1820
+ case "localFileName": {
1821
+ const resolvedNameLink = resolveNameLink(decodedUrl, cwd, allFilePaths ?? []);
1822
+ return resolvedNameLink === void 0 ? resolveLocalFilePath(decodedUrl, resolvedOptions) : resolveMatchedLocalLink(resolvedNameLink, resolvedOptions);
1821
1823
  }
1822
- if (sourceType === "localFileUrl") {
1823
- const resolvedUrl = normalize(fileUrlToPath(currentPathOrUrl));
1824
- if (getSrcType(resolvedUrl) === "localFilePath") {
1825
- currentPathOrUrl = resolvedUrl;
1826
- continue;
1827
- }
1828
- console.warn(`Failed to convert file URL to path: ${currentPathOrUrl} --> ${resolvedUrl}`);
1829
- return resolvedUrl;
1830
- }
1831
- switch (sourceType) {
1832
- case "localFilePath": return resolveLocalFilePath(decodedUrl, {
1833
- allFilePaths,
1834
- basePath,
1835
- convertFilePathsToProtocol,
1836
- cwd,
1837
- obsidianVaultName,
1838
- type
1839
- });
1840
- case "obsidianVaultUrl": return currentPathOrUrl;
1841
- case "remoteHttpUrl": return currentPathOrUrl;
1842
- case "unsupportedProtocolUrl":
1843
- console.warn(`Unsupported URL protocol: ${currentPathOrUrl}`);
1844
- return currentPathOrUrl;
1824
+ case "localFilePath": return resolveLocalFilePath(decodedUrl, resolvedOptions);
1825
+ case "localFileUrl": {
1826
+ const encodedFilePath = fileUrlToPath(filePathOrUrl);
1827
+ return resolveLocalFilePath(safeDecodeURI(encodedFilePath) ?? encodedFilePath, resolvedOptions);
1845
1828
  }
1829
+ case "obsidianVaultUrl":
1830
+ case "remoteHttpUrl": return filePathOrUrl;
1831
+ case "unsupportedProtocolUrl":
1832
+ console.warn(`Unsupported URL protocol: ${filePathOrUrl}`);
1833
+ return filePathOrUrl;
1846
1834
  }
1847
1835
  }
1848
1836
  /**
@@ -1851,20 +1839,33 @@ function resolveLink(filePathOrUrl, options) {
1851
1839
  * `max-depth` lint rule).
1852
1840
  */
1853
1841
  function resolveLocalFilePath(decodedUrl, options) {
1854
- const { allFilePaths, basePath, convertFilePathsToProtocol, cwd, obsidianVaultName, type } = options;
1855
- const resolvedUrl = resolveWithBasePath(normalize(decodedUrl), {
1842
+ const { allFilePaths, basePath, cwd } = options;
1843
+ const candidates = getLocalPathCandidates(decodedUrl);
1844
+ for (const { anchor, filePath } of candidates) {
1845
+ const resolvedBase = resolveWithBasePath(normalize(filePath), {
1846
+ basePath,
1847
+ cwd
1848
+ });
1849
+ const matchedPath = (path.extname(resolvedBase) === "" ? [`${resolvedBase}.md`] : [resolvedBase, `${resolvedBase}.md`]).find((candidate) => pathExistsInAllFiles(candidate, allFilePaths ?? []));
1850
+ if (matchedPath !== void 0) return resolveMatchedLocalLink({
1851
+ anchor,
1852
+ filePath: matchedPath
1853
+ }, options);
1854
+ }
1855
+ return resolveWithBasePath(normalize(candidates.at(-1)?.filePath ?? decodedUrl), {
1856
1856
  basePath,
1857
1857
  cwd
1858
1858
  });
1859
- const resolvedUrlWithMatchedExtension = pathExistsInAllFiles(addExtensionIfMissing(resolvedUrl, "md"), allFilePaths ?? []) ?? pathExistsInAllFiles(addExtension(resolvedUrl, "md"), allFilePaths ?? []) ?? void 0;
1860
- if (resolvedUrlWithMatchedExtension !== void 0) {
1861
- if (convertFilePathsToProtocol !== "none" && (type === "link" || type === "embed" && [".md", ".pdf"].includes(getExtension(resolvedUrlWithMatchedExtension)))) {
1862
- if (convertFilePathsToProtocol === "obsidian" && obsidianVaultName !== void 0) return createObsidianVaultLink(resolvedUrlWithMatchedExtension, basePath ?? "", obsidianVaultName);
1863
- if (convertFilePathsToProtocol === "file") return createFileLink(resolvedUrlWithMatchedExtension);
1864
- }
1865
- return getBase(resolvedUrlWithMatchedExtension);
1859
+ }
1860
+ /** Render a matched local file and its still-unmodified Obsidian anchor. */
1861
+ function resolveMatchedLocalLink(link, options) {
1862
+ const { anchor, filePath } = link;
1863
+ const { basePath, convertFilePathsToProtocol, obsidianVaultName, type } = options;
1864
+ if (convertFilePathsToProtocol !== "none" && (type === "link" || type === "embed" && [".md", ".pdf"].includes(path.extname(filePath)))) {
1865
+ if (convertFilePathsToProtocol === "obsidian" && obsidianVaultName !== void 0) return createObsidianVaultLink(link, basePath ?? "", obsidianVaultName);
1866
+ if (convertFilePathsToProtocol === "file") return createFileLink(`${filePath}${anchor ?? ""}`);
1866
1867
  }
1867
- return getBase(resolvedUrl);
1868
+ return filePath;
1868
1869
  }
1869
1870
  /**
1870
1871
  * Convert from a (usually wiki-style) named link to an absolute path to an
@@ -1875,27 +1876,52 @@ function resolveLocalFilePath(decodedUrl, options) {
1875
1876
  * Obsidian seems to treat note links slightly differently from image / asset
1876
1877
  * links.
1877
1878
  *
1878
- * @param name Non-URI-encoded name of the file, may have file extension, if no
1879
- * match with a non-.md extension is found, a match will be attempted with .md
1880
- * regardless. (POSIX-style paths.)
1879
+ * @param name Non-URI-encoded, normalized name of the file, with or without a
1880
+ * file extension. Extension-less names are assumed to be .md files. May
1881
+ * include an anchor suffix, or anchor delimiter characters that are a literal
1882
+ * part of the file name. (POSIX-style paths.)
1881
1883
  * @param cwd Absolute path to the current working directory of the file from
1882
1884
  * which we're resolving the link. (POSIX-style paths)
1883
1885
  * @param allFilePaths Array of absolute paths to all other files in the paths
1884
1886
  * to be considered. (POSIX-style paths.)
1885
1887
  *
1886
- * @returns Absolute path to the best matching file with the name provided, or
1887
- * undefined if there's no valid match. (POSIX-style paths.)
1888
+ * @returns The absolute path to the best matching file and its unmodified
1889
+ * anchor, or undefined if there's no valid match. (POSIX-style path.)
1888
1890
  */
1889
1891
  function resolveNameLink(name, cwd, allFilePaths) {
1890
1892
  if (allFilePaths.length === 0) return;
1891
- const [base, query] = getBaseAndQueryParts(name);
1893
+ for (const { anchor, filePath } of getLocalPathCandidates(name)) {
1894
+ const normalizedBase = normalize(filePath);
1895
+ const match = findBestNameMatch(path.extname(normalizedBase) === "" ? `${normalizedBase}.md` : normalizedBase, cwd, allFilePaths);
1896
+ if (match !== void 0) return {
1897
+ anchor,
1898
+ filePath: match
1899
+ };
1900
+ }
1901
+ }
1902
+ /**
1903
+ * Find the best matching file for a name in the list of all file paths.
1904
+ * Extracted from `resolveNameLink` so each base and anchor interpretation of
1905
+ * the name can be attempted in turn.
1906
+ *
1907
+ * @param base Non-URI-encoded name of the file with a file extension and no
1908
+ * anchor suffix. (POSIX-style paths.)
1909
+ * @param cwd Absolute path to the current working directory of the file from
1910
+ * which we're resolving the link. (POSIX-style paths)
1911
+ * @param allFilePaths Array of absolute paths to all other files in the paths
1912
+ * to be considered. (POSIX-style paths.)
1913
+ *
1914
+ * @returns Absolute path to the best matching file, or undefined if there's no
1915
+ * valid match. (POSIX-style paths.)
1916
+ */
1917
+ function findBestNameMatch(base, cwd, allFilePaths) {
1892
1918
  const baseWithoutMd = base.replace(MD_EXTENSION_REGEX, "").toLowerCase();
1893
1919
  const pathsToName = allFilePaths.filter((filePath) => {
1894
1920
  return filePath.replace(MD_EXTENSION_REGEX, "").toLowerCase().endsWith(baseWithoutMd);
1895
1921
  });
1896
1922
  if (pathsToName.length === 0) return;
1897
- if (pathsToName.length === 1) return `${pathsToName[0]}${query ?? ""}`;
1898
- return `${pathsToName.toSorted((a, b) => {
1923
+ if (pathsToName.length === 1) return pathsToName[0];
1924
+ return pathsToName.toSorted((a, b) => {
1899
1925
  if (!base.endsWith(".md") || base.includes(path.sep)) {
1900
1926
  const aHasCwd = a.startsWith(cwd);
1901
1927
  if (aHasCwd !== b.startsWith(cwd)) return aHasCwd ? -1 : 1;
@@ -1904,29 +1930,29 @@ function resolveNameLink(name, cwd, allFilePaths) {
1904
1930
  const bDepth = b.split(path.sep).length;
1905
1931
  if (aDepth !== bDepth) return aDepth - bDepth;
1906
1932
  return a.localeCompare(b);
1907
- })[0]}${query ?? ""}`;
1933
+ })[0];
1908
1934
  }
1909
1935
  /**
1910
- * Check for presence of a path in a list in a case- and query- agnostic manner.
1911
- * Ignores .md extensions to simplify matching files
1936
+ * Check for presence of a path in a list in a case-agnostic manner, since
1937
+ * Obsidian is not case sensitive.
1912
1938
  *
1913
- * @param filePath File path with file extension. (POSIX-style path.)
1939
+ * @param filePath Literal file path with file extension and no anchor suffix.
1940
+ * (POSIX-style path.)
1914
1941
  * @param allFilePaths Array of absolute file paths to check. (POSIX-style
1915
1942
  * paths.)
1916
1943
  *
1917
- * @returns The file path if it is present in the list of all file paths, or
1918
- * undefined if it is not.
1944
+ * @returns Whether the file path is present in the list of all file paths.
1919
1945
  */
1920
1946
  function pathExistsInAllFiles(filePath, allFilePaths) {
1921
- const base = getBase(filePath);
1922
- if (allFilePaths.some((file) => file.toLowerCase().endsWith(base.toLowerCase()))) return filePath;
1947
+ const filePathLowerCase = filePath.toLowerCase();
1948
+ return allFilePaths.some((file) => file.toLowerCase().endsWith(filePathLowerCase));
1923
1949
  }
1924
1950
  function createFileLink(absolutePath) {
1925
1951
  return `file://${absolutePath}`;
1926
1952
  }
1927
- function createObsidianVaultLink(absolutePath, basePath, obsidianVault) {
1928
- const relativePath = stripBasePath(absolutePath, basePath);
1929
- return `obsidian://open?vault=${encodeURIComponent(obsidianVault)}&file=${encodeURIComponent(relativePath)}`;
1953
+ function createObsidianVaultLink(link, basePath, obsidianVault) {
1954
+ const linkPath = `${stripBasePath(link.filePath, basePath)}${link.anchor ?? ""}`;
1955
+ return `obsidian://open?vault=${encodeURIComponent(obsidianVault)}&file=${encodeURIComponent(linkPath)}`;
1930
1956
  }
1931
1957
  //#endregion
1932
1958
  //#region src/lib/parse/remark-resolve-links.ts
@@ -1948,7 +1974,7 @@ const plugin$1 = function(options) {
1948
1974
  obsidianVaultName: obsidianVault,
1949
1975
  type: "link"
1950
1976
  });
1951
- node.url = isUrl(resolvedLink) ? resolvedLink : encodeURI(resolvedLink);
1977
+ node.url = isUrl(resolvedLink) ? resolvedLink : encodeURI(resolvedLink).replaceAll("?", "%3F").replaceAll("#", "%23");
1952
1978
  });
1953
1979
  visit(tree, "image", (node) => {
1954
1980
  node.data ??= {};