diff --git a/packages/remark-wiki-link/.babelrc b/packages/remark-wiki-link/.babelrc new file mode 100644 index 00000000..2fdda153 --- /dev/null +++ b/packages/remark-wiki-link/.babelrc @@ -0,0 +1,4 @@ +{ + "presets": ["@babel/preset-env"], + "plugins": ["@babel/plugin-transform-runtime"] +} diff --git a/packages/remark-wiki-link/.eslintrc.json b/packages/remark-wiki-link/.eslintrc.json new file mode 100644 index 00000000..ccb6ab44 --- /dev/null +++ b/packages/remark-wiki-link/.eslintrc.json @@ -0,0 +1,7 @@ +{ + "extends": ["../../.eslintrc.json"], + "env": { + "jest": true + }, + "ignorePatterns": ["dist/**/*"] +} diff --git a/packages/remark-wiki-link/.npmignore b/packages/remark-wiki-link/.npmignore new file mode 100644 index 00000000..281df393 --- /dev/null +++ b/packages/remark-wiki-link/.npmignore @@ -0,0 +1,2 @@ +src +test diff --git a/packages/remark-wiki-link/CHANGELOG.md b/packages/remark-wiki-link/CHANGELOG.md new file mode 100644 index 00000000..2bc2d05e --- /dev/null +++ b/packages/remark-wiki-link/CHANGELOG.md @@ -0,0 +1,32 @@ +# @flowershow/remark-wiki-link + +## 1.2.0 + +### Minor Changes + +- 5ef0262: Don't hyphentate or lowercase src/hrefs by default. Add ignore patterns option to `getPermalinks` util function and allow passing custom paths -> permalinks converter function. + +## 1.1.2 + +### Patch Changes + +- 135a238: Small regex fix in pageResolver. + +## 1.1.1 + +### Patch Changes + +- 71110e2: Fix: wiki links to index pages. +- ffa9766: Fix wiki links to headings on the same page. + +## 1.1.0 + +### Minor Changes + +- 22fb5f0: Code refactoring and test converage extension. Also, disabling markdownFolder option in favour of exported getPermalinks function that should be used by the user to generate permalinks list and explicitly pass it to the plugin. + +## 1.0.1 + +### Patch Changes + +- ae2bf0d: Dynamic import `getFiles` function, only when `markdownFolder` is passed as an option. diff --git a/packages/remark-wiki-link/README.md b/packages/remark-wiki-link/README.md new file mode 100644 index 00000000..bcd5bd83 --- /dev/null +++ b/packages/remark-wiki-link/README.md @@ -0,0 +1,132 @@ +# remark-wiki-link + +Parse and render wiki-style links in markdown especially Obsidian style links. + +## What is this ? + +Using obsidian, when we type in wiki link syntax for eg. `[[wiki_link]]` it would parse them as anchors. + +## Features supported + +- [x] Support `[[Internal link]]` +- [x] Support `[[Internal link|With custom text]]` +- [x] Support `[[Internal link#heading]]` +- [x] Support `[[Internal link#heading|With custom text]]` +- [x] Support `![[Document.pdf]]` +- [x] Support `![[Image.png]]` + +* Supported image formats are jpg, jpeg, png, apng, webp, gif, svg, bmp, ico +* Unsupported image formats will display a raw wiki link string, e.g. `[[Image.xyz]]`. + +Future support: + +- [ ] Support `![[Audio.mp3]]` +- [ ] Support `![[Video.mp4]]` +- [ ] Support `![[Embed note]]` +- [ ] Support `![[Embed note#heading]]` + +## Installation + +```bash +npm install @flowershow/remark-wiki-link +``` + +## Usage + +```javascript +import unified from "unified"; +import markdown from "remark-parse"; +import wikiLinkPlugin from "@flowershow/remark-wiki-link"; + +const processor = unified().use(markdown).use(wikiLinkPlugin); +``` + +## Configuration options + +### `pathFormat` + +Type: `"raw" | "obisidan-absolute" | "obsidian-short"` +Default: `"raw"` + +- `"raw"`: use this option for regular relative or absolute paths (or Obsidian relative paths), e.g. `[[../some/folder/file]]` or `[[[/some/folder/file]]]`, +- `"obsidian-absolute"`: use this option for Obsidian absolute paths, i.e. paths with no leading `/`, e.g. `[[some/folder/file]]` +- `"obsidian-short"`: use this option for Obsidian shortened paths, e.g. `[[file]]` to resolve them to absolute paths. Note that apart from setting this value, you will also need to pass a list of paths to files in your content folder, and pass it as `permalinks` option. You can generate this list yourself or use our util function `getPermalinks`. See below for more info. + +> [!note] +> Wiki link format in Obsidian can be configured in Settings -> Files & Links -> New link format. + +### `aliasDivider` + +Type: single character string +Default: `"|"` + +Alias divider character used in your wiki links. E.g. `[[/some/folder/file|Alias]]` + +### `permalinks` + +Type: `Array` +Default: `[]` + +A list of permalinks you want to match your wiki link paths with. Wiki links with matched permalinks will have `node.data.exists` property set to `true`. Wiki links with no matching permalinks will also have additional class `new` set. + +### `wikiLinkResolver` + +Type: `(name: string) => Array` +Default: `(name: string) => name.replace(/\/index$/, "")` (simplified; see source code for full version) + +A function that will take the wiki link target page (e.g. `"/some/folder/file"` in `[[/some/folder/file#Some Heading|Some Alias]]` wiki link) and return an array of pages to which the wiki link **can** be resolved (one of them will be used, depending on wheather `pemalinks` are passed, and if match is found). + +If `permalinks` are passed, the resulting array will be matched against these permalinks to find the match. The matching pemalink will be used as node's `href` (or `src` for images). + +If no matching permalink is found, the first item from the array returned by this function will be used as a node's `href` (or `src` for images). So, if you want to write a custom wiki link -> url + +### `newClassName` + +Type: `string` +Default: `"new"` + +Class name added to nodes created for wiki links for which no matching permalink (passed in `permalinks` option) was found. + +### `wikiLinkClassName` + +Type: `string` +Default: `"internal"` + +Class name added to all wiki link nodes. + +### `hrefTemplate` + +Type: `(permalink: string) => string` +Default: `(permalink: string) => permalink` + +A function that will be used to convert a matched permalink of the wiki link to `href` (or `src` for images). + +### `markdownFolder` ❌ (deprecated as of version 1.1.0) + +A string that points to the content folder, that will be used to resolve Obsidian shortened wiki link path format. + +Instead of using this option, use e.g. `getPermalinks` util function exported from this package to generate a list of permalinks from your content folder, and pass it explicitly as `permalinks` option. + +## Generating list of permalinks from content folder with `getPermalinks` + +If you're using shortened path format for your Obsidian wiki links, in order to resolve them correctly to paths they point to, you need to set `option.pathFormat: "obsidian-short"` but also provide the plugin with a list of permalinks that point to files in your content folder as `option.permalinks`. You can use your own script to generate this list or use our util function `getPermalinks` like so: + +```javascript {4,6,11-12} +import unified from "unified"; +import markdown from "remark-parse"; +import wikiLinkPlugin from "@flowershow/remark-wiki-link"; +import { getPermalinks } from "@flowershow/remark-wiki-link"; + +const permalinks = await getPermalinks("path-to-your-content-folder"); + +const processor = unified().use(markdown).use(wikiLinkPlugin, { + pathFormat: "obsidian-short", + permalinks, +}); +``` + +## Running tests + +```bash +pnpm nx test remark-wiki-link +``` diff --git a/packages/remark-wiki-link/jest.config.ts b/packages/remark-wiki-link/jest.config.ts new file mode 100644 index 00000000..395a20e7 --- /dev/null +++ b/packages/remark-wiki-link/jest.config.ts @@ -0,0 +1,14 @@ +import type { JestConfigWithTsJest } from "ts-jest"; + +const jestConfig: JestConfigWithTsJest = { + displayName: "remark-wiki-link", + preset: "ts-jest", + testEnvironment: "node", + transform: { + "^.+\\.[tj]s?$": "ts-jest", + }, + moduleFileExtensions: ["ts", "js"], + transformIgnorePatterns: ["/node_modules/(?!remark-parse)"], +}; + +export default jestConfig; diff --git a/packages/remark-wiki-link/package.json b/packages/remark-wiki-link/package.json new file mode 100644 index 00000000..49d652a3 --- /dev/null +++ b/packages/remark-wiki-link/package.json @@ -0,0 +1,43 @@ +{ + "name": "@flowershow/remark-wiki-link", + "version": "1.2.0", + "description": "Parse and render wiki-style links in markdown especially Obsidian style links.", + "repository": { + "type": "git", + "url": "git+https://github.com/flowershow/flowershow.git", + "directory": "packages/remark-wiki-link" + }, + "keywords": [ + "remark", + "remark-plugin", + "markdown", + "gfm", + "obsidian" + ], + "author": "Rufus Pollock", + "license": "MIT", + "bugs": { + "url": "https://github.com/flowershow/flowershow/issues" + }, + "homepage": "https://github.com/flowershow/flowershow#readme", + "publishConfig": { + "access": "public" + }, + "dependencies": { + "mdast-util-to-markdown": "^1.5.0", + "mdast-util-wiki-link": "^0.0.2", + "micromark-util-symbol": "^1.0.1" + }, + "type": "module", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "require": "./dist/index.cjs" + } + }, + "devDependencies": { + "micromark": "^3.1.0", + "remark-gfm": "^3.0.1" + } +} diff --git a/packages/remark-wiki-link/project.json b/packages/remark-wiki-link/project.json new file mode 100644 index 00000000..e327dbc9 --- /dev/null +++ b/packages/remark-wiki-link/project.json @@ -0,0 +1,63 @@ +{ + "name": "remark-wiki-link", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "packages/remark-wiki-link/src", + "projectType": "library", + "targets": { + "lint": { + "executor": "@nrwl/linter:eslint", + "outputs": ["{options.outputFile}"], + "options": { + "lintFilePatterns": ["packages/remark-wiki-link/**/*.{ts,js}"] + } + }, + "test": { + "executor": "@nrwl/jest:jest", + "outputs": ["{workspaceRoot}/coverage/{projectRoot}"], + "options": { + "jestConfig": "packages/remark-wiki-link/jest.config.ts", + "passWithNoTests": true + } + }, + "build": { + "executor": "@nrwl/web:rollup", + "outputs": ["{options.outputPath}"], + "options": { + "entryFile": "packages/remark-wiki-link/src/index.ts", + "outputPath": "packages/remark-wiki-link/dist", + "compiler": "babel", + "tsConfig": "packages/remark-wiki-link/tsconfig.lib.json", + "project": "packages/remark-wiki-link/package.json", + "format": ["esm", "cjs"], + "external": ["mdast-util-wiki-link"], + "generateExportsField": true, + "assets": [ + { + "glob": "packages/remark-wiki-link/README.md", + "input": ".", + "output": "." + } + ] + } + }, + "publish:dry": { + "executor": "nx:run-commands", + "options": { + "commands": ["npm publish --dry-run"], + "parallel": false, + "cwd": "packages/remark-wiki-link/dist" + }, + "dependsOn": ["build"] + }, + "publish": { + "executor": "nx:run-commands", + "options": { + "commands": ["npm publish"], + "parallel": false, + "cwd": "packages/remark-wiki-link/dist" + }, + "dependsOn": ["build"] + } + }, + "tags": [] +} diff --git a/packages/remark-wiki-link/src/index.ts b/packages/remark-wiki-link/src/index.ts new file mode 100644 index 00000000..8e6caaa7 --- /dev/null +++ b/packages/remark-wiki-link/src/index.ts @@ -0,0 +1,3 @@ +export * from "./lib/remarkWikiLink"; +export { default } from "./lib/remarkWikiLink"; +export { getPermalinks } from "./utils"; diff --git a/packages/remark-wiki-link/src/lib/fromMarkdown.ts b/packages/remark-wiki-link/src/lib/fromMarkdown.ts new file mode 100644 index 00000000..469cd2e0 --- /dev/null +++ b/packages/remark-wiki-link/src/lib/fromMarkdown.ts @@ -0,0 +1,172 @@ +import { isSupportedFileFormat } from "./isSupportedFileFormat"; + +const defaultWikiLinkResolver = (target: string) => { + // for [[#heading]] links + if (!target) { + return []; + } + let permalink = target.replace(/\/index$/, ""); + // TODO what to do with [[index]] link? + if (permalink.length === 0) { + permalink = "/"; + } + return [permalink]; +}; + +export interface FromMarkdownOptions { + pathFormat?: + | "raw" // default; use for regular relative or absolute paths + | "obsidian-absolute" // use for Obsidian-style absolute paths (with no leading slash) + | "obsidian-short"; // use for Obsidian-style shortened paths (shortest path possible) + permalinks?: string[]; // list of permalinks to match possible permalinks of a wiki link against + wikiLinkResolver?: (name: string) => string[]; // function to resolve wiki links to an array of possible permalinks + newClassName?: string; // class name to add to links that don't have a matching permalink + wikiLinkClassName?: string; // class name to add to all wiki links + hrefTemplate?: (permalink: string) => string; // function to generate the href attribute of a link +} + +// mdas-util-from-markdown extension +// https://github.com/syntax-tree/mdast-util-from-markdown#extension +function fromMarkdown(opts: FromMarkdownOptions = {}) { + const pathFormat = opts.pathFormat || "raw"; + const permalinks = opts.permalinks || []; + const wikiLinkResolver = opts.wikiLinkResolver || defaultWikiLinkResolver; + const newClassName = opts.newClassName || "new"; + const wikiLinkClassName = opts.wikiLinkClassName || "internal"; + const defaultHrefTemplate = (permalink: string) => permalink; + + const hrefTemplate = opts.hrefTemplate || defaultHrefTemplate; + + function top(stack) { + return stack[stack.length - 1]; + } + + function enterWikiLink(token) { + this.enter( + { + type: "wikiLink", + data: { + isEmbed: token.isType === "embed", + target: null, // the target of the link, e.g. "Foo Bar#Heading" in "[[Foo Bar#Heading]]" + alias: null, // the alias of the link, e.g. "Foo" in "[[Foo Bar|Foo]]" + permalink: null, // TODO shouldn't this be named just "link"? + exists: null, // TODO is this even needed here? + // fields for mdast-util-to-hast (used e.g. by remark-rehype) + hName: null, + hProperties: null, + hChildren: null, + }, + }, + token + ); + } + + function exitWikiLinkTarget(token) { + const target = this.sliceSerialize(token); + const current = top(this.stack); + current.data.target = target; + } + + function exitWikiLinkAlias(token) { + const alias = this.sliceSerialize(token); + const current = top(this.stack); + current.data.alias = alias; + } + + function exitWikiLink(token) { + const wikiLink = this.exit(token); + const { + data: { isEmbed, target, alias }, + } = wikiLink; + // eslint-disable-next-line no-useless-escape + const wikiLinkWithHeadingPattern = /([\w\s\/\.-]*)(#.*)?/; + const [, path, heading = ""] = target.match(wikiLinkWithHeadingPattern); + + const possibleWikiLinkPermalinks = wikiLinkResolver(path); + + const matchingPermalink = permalinks.find((e) => { + return possibleWikiLinkPermalinks.find((p) => { + if (pathFormat === "obsidian-short") { + if (e === p || e.endsWith(p)) { + return true; + } + } else if (pathFormat === "obsidian-absolute") { + if (e === "/" + p) { + return true; + } + } else { + if (e === p) { + return true; + } + } + return false; + }); + }); + + // TODO this is ugly + const link = + matchingPermalink || + (pathFormat === "obsidian-absolute" + ? "/" + possibleWikiLinkPermalinks[0] + : possibleWikiLinkPermalinks[0]) || + ""; + + wikiLink.data.exists = !!matchingPermalink; + wikiLink.data.permalink = link; + + // remove leading # if the target is a heading on the same page + const displayName = alias || target.replace(/^#/, ""); + const headingId = heading.replace(/\s+/, "-").toLowerCase(); + let classNames = wikiLinkClassName; + if (!matchingPermalink) { + classNames += " " + newClassName; + } + + if (isEmbed) { + const [isSupportedFormat, format] = isSupportedFileFormat(target); + if (!isSupportedFormat) { + wikiLink.data.hName = "p"; + wikiLink.data.hChildren = [ + { + type: "text", + value: `![[${target}]]`, + }, + ]; + } else if (format === "pdf") { + wikiLink.data.hName = "iframe"; + wikiLink.data.hProperties = { + className: classNames, + width: "100%", + src: `${hrefTemplate(link)}#toolbar=0`, + }; + } else { + wikiLink.data.hName = "img"; + wikiLink.data.hProperties = { + className: classNames, + src: hrefTemplate(link), + alt: displayName, + }; + } + } else { + wikiLink.data.hName = "a"; + wikiLink.data.hProperties = { + className: classNames, + href: hrefTemplate(link) + headingId, + }; + wikiLink.data.hChildren = [{ type: "text", value: displayName }]; + } + } + + return { + enter: { + wikiLink: enterWikiLink, + }, + exit: { + wikiLinkTarget: exitWikiLinkTarget, + wikiLinkAlias: exitWikiLinkAlias, + wikiLink: exitWikiLink, + }, + }; +} + +export { fromMarkdown }; diff --git a/packages/remark-wiki-link/src/lib/html.ts b/packages/remark-wiki-link/src/lib/html.ts new file mode 100644 index 00000000..8f23278d --- /dev/null +++ b/packages/remark-wiki-link/src/lib/html.ts @@ -0,0 +1,146 @@ +import { isSupportedFileFormat } from "./isSupportedFileFormat"; + +const defaultWikiLinkResolver = (target: string) => { + // for [[#heading]] links + if (!target) { + return []; + } + let permalink = target.replace(/\/index$/, ""); + // TODO what to do with [[index]] link? + if (permalink.length === 0) { + permalink = "/"; + } + return [permalink]; +}; + +export interface HtmlOptions { + pathFormat?: + | "raw" // default; use for regular relative or absolute paths + | "obsidian-absolute" // use for Obsidian-style absolute paths (with no leading slash) + | "obsidian-short"; // use for Obsidian-style shortened paths (shortest path possible) + permalinks?: string[]; // list of permalinks to match possible permalinks of a wiki link against + wikiLinkResolver?: (name: string) => string[]; // function to resolve wiki links to an array of possible permalinks + newClassName?: string; // class name to add to links that don't have a matching permalink + wikiLinkClassName?: string; // class name to add to all wiki links + hrefTemplate?: (permalink: string) => string; // function to generate the href attribute of a link +} + +// Micromark HtmlExtension +// https://github.com/micromark/micromark#htmlextension +function html(opts: HtmlOptions = {}) { + const pathFormat = opts.pathFormat || "raw"; + const permalinks = opts.permalinks || []; + const wikiLinkResolver = opts.wikiLinkResolver || defaultWikiLinkResolver; + const newClassName = opts.newClassName || "new"; + const wikiLinkClassName = opts.wikiLinkClassName || "internal"; + const defaultHrefTemplate = (permalink: string) => permalink; + const hrefTemplate = opts.hrefTemplate || defaultHrefTemplate; + + function top(stack) { + return stack[stack.length - 1]; + } + + function enterWikiLink() { + let stack = this.getData("wikiLinkStack"); + if (!stack) this.setData("wikiLinkStack", (stack = [])); + + stack.push({}); + } + + function exitWikiLinkTarget(token) { + const target = this.sliceSerialize(token); + const current = top(this.getData("wikiLinkStack")); + current.target = target; + } + + function exitWikiLinkAlias(token) { + const alias = this.sliceSerialize(token); + const current = top(this.getData("wikiLinkStack")); + current.alias = alias; + } + + function exitWikiLink(token) { + const wikiLink = this.getData("wikiLinkStack").pop(); + const { target, alias } = wikiLink; + const isEmbed = token.isType === "embed"; + // eslint-disable-next-line no-useless-escape + const wikiLinkWithHeadingPattern = /([\w\s\/\.-]*)(#.*)?/; + const [, path, heading = ""] = target.match(wikiLinkWithHeadingPattern); + + const possibleWikiLinkPermalinks = wikiLinkResolver(path); + + const matchingPermalink = permalinks.find((e) => { + return possibleWikiLinkPermalinks.find((p) => { + if (pathFormat === "obsidian-short") { + if (e === p || e.endsWith(p)) { + return true; + } + } else if (pathFormat === "obsidian-absolute") { + if (e === "/" + p) { + return true; + } + } else { + if (e === p) { + return true; + } + } + return false; + }); + }); + + // TODO this is ugly + const link = + matchingPermalink || + (pathFormat === "obsidian-absolute" + ? "/" + possibleWikiLinkPermalinks[0] + : possibleWikiLinkPermalinks[0]) || + ""; + + // remove leading # if the target is a heading on the same page + const displayName = alias || target.replace(/^#/, ""); + // replace spaces with dashes and lowercase headings + const headingId = heading.replace(/\s+/, "-").toLowerCase(); + let classNames = wikiLinkClassName; + if (!matchingPermalink) { + classNames += " " + newClassName; + } + + if (isEmbed) { + const [isSupportedFormat, format] = isSupportedFileFormat(target); + if (!isSupportedFormat) { + this.raw(`![[${target}]]`); + } else if (format === "pdf") { + this.tag( + `