├── .babelrc ├── .eslintrc.js ├── .github └── workflows │ └── node.js.yml ├── .gitignore ├── .npmignore ├── LICENSE ├── README.md ├── package-lock.json ├── package.json ├── rollup.config.js ├── src ├── from-markdown.js ├── getFiles.js ├── html.js ├── index.js └── syntax.js └── test └── index_test.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["@babel/preset-env"], 3 | "plugins": ["@babel/plugin-transform-runtime"] 4 | } 5 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | browser: true, 4 | es2021: true, 5 | node: true, 6 | mocha: true 7 | }, 8 | extends: [ 9 | 'standard' 10 | ], 11 | parserOptions: { 12 | ecmaVersion: 12, 13 | sourceType: 'module' 14 | }, 15 | rules: { 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /.github/workflows/node.js.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions 3 | 4 | name: Node.js CI 5 | 6 | on: 7 | push: 8 | branches: [ master ] 9 | pull_request: 10 | branches: [ master ] 11 | 12 | jobs: 13 | build: 14 | 15 | runs-on: ubuntu-latest 16 | 17 | strategy: 18 | matrix: 19 | node-version: [10.x, 12.x, 14.x, 15.x] 20 | # See supported Node.js release schedule at https://nodejs.org/en/about/releases/ 21 | 22 | steps: 23 | - uses: actions/checkout@v2 24 | - name: Use Node.js ${{ matrix.node-version }} 25 | uses: actions/setup-node@v1 26 | with: 27 | node-version: ${{ matrix.node-version }} 28 | - run: npm ci 29 | - run: npm test 30 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | lib 3 | dist 4 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | src 2 | test 3 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Life Itself 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 🛑 This repository has been merged into [Flowershow monorepo](https://github.com/flowershow/flowershow). 2 | 3 | # remark-wiki-link-plus 4 | 5 | Parse and render wiki-style links in markdown especially Obsidian style links. 6 | 7 | ## What is this ? 8 | 9 | Using obsidian, when we type in wiki link syntax for eg. `[[wiki_link]]` it would parse them as anchors. 10 | 11 | ## Features supported 12 | 13 | - [x] Support `[[Internal link]]` 14 | - [x] Support `[[Internal link|With custom text]]` 15 | - [x] Support `[[Internal link#heading]]` 16 | - [x] Support `[[Internal link#heading|With custom text]]` 17 | - [x] Support `![[Document.pdf]]` 18 | - [x] Support `![[Image.png]]` 19 | * Supported image formats are jpg, jpeg, png, apng, webp, gif, svg, bmp, ico 20 | * Unsupported image formats display a message for eg. `![[Image.xyz]]` would render the following: 21 | `Document type XYZ is not yet supported for transclusions` 22 | 23 | Future support: 24 | - [ ] Support `![[Audio.mp3]]` 25 | - [ ] Support `![[Video.mp4]]` 26 | - [ ] Support `![[Embed note]]` 27 | - [ ] Support `![[Embed note#heading]]` 28 | 29 | ## Installation 30 | 31 | ```bash 32 | npm install remark-wiki-link-plus 33 | ``` 34 | 35 | ## Usage 36 | 37 | ```javascript 38 | const unified = require('unified') 39 | const markdown = require('remark-parse') 40 | const wikiLinkPlugin = require('remark-wiki-link-plus'); 41 | 42 | let processor = unified() 43 | .use(markdown, { gfm: true }) 44 | .use(wikiLinkPlugin) 45 | ``` 46 | 47 | ### Configuration options 48 | 49 | * `options.markdownFolder [String]`: A string that points to the content folder. 50 | 51 | The default `hrefTemplate` is: 52 | 53 | ```javascript 54 | (permalink) => `/${permalink}` 55 | ``` 56 | 57 | ## Running the tests 58 | 59 | ```bash 60 | npm run test 61 | ``` 62 | 63 | # Change Log 64 | 65 | ## [1.1.1] - 2022-11-14 66 | 67 | ### Fixed 68 | 69 | - Permalinks not linking to case sensitive url paths 70 | - eg. before `[[Page]]` generates `href="/page"` but will now generate `href="/Page"` 71 | 72 | ## [1.1.0] - 2022-09-06 73 | 74 | ### Added 75 | 76 | - Add support for more image formats 77 | - apng, webp, gif, svg, bmp, ico 78 | - Add support for PDF documents 79 | - Add warning for unsupported image formats 80 | 81 | ## [1.0.2] - 2022-08-11 82 | 83 | ### Added 84 | 85 | - Add support for transclusion links / image links 86 | - png, jpg, jpeg 87 | 88 | ## [1.0.1] - 2022-08-04 89 | 90 | ### Changed 91 | 92 | - permalink for folders with an index file will tranform to folder name only. 93 | For example, if the wikilink is [[docs/index]] the href will be '/docs'. 94 | 95 | ### Fixed 96 | 97 | - broken links to filenames that matched the markdown folder name. 98 | 99 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "remark-wiki-link-plus", 3 | "version": "1.1.1", 4 | "description": "Parse and render wiki-style links in markdown especially Obsidian style links.", 5 | "repository": { 6 | "type": "git", 7 | "url": "git+https://github.com/life-itself/remark-wiki-link-plus.git" 8 | }, 9 | "keywords": [ 10 | "remark", 11 | "remark-plugin", 12 | "markdown", 13 | "gfm", 14 | "obsidian" 15 | ], 16 | "author": "Life Itself", 17 | "license": "MIT", 18 | "bugs": { 19 | "url": "https://github.com/life-itself/remark-wiki-link-plus/issues" 20 | }, 21 | "homepage": "https://github.com/life-itself/remark-wiki-link-plus#readme", 22 | "main": "dist/index.cjs.js", 23 | "module": "dist/index.esm.js", 24 | "browser": "dist/index.umd.js", 25 | "scripts": { 26 | "build": "rollup -c", 27 | "lint": "eslint src/ test/", 28 | "prepare": "npm run build", 29 | "pretest": "npm run build", 30 | "test": "npm run lint && mocha --require @babel/register test/index_test.js" 31 | }, 32 | "dependencies": { 33 | "@babel/runtime": "^7.4.4", 34 | "mdast-util-wiki-link": "^0.0.2", 35 | "micromark-extension-wiki-link": "^0.0.4" 36 | }, 37 | "devDependencies": { 38 | "@babel/cli": "^7.12.10", 39 | "@babel/core": "^7.4.4", 40 | "@babel/plugin-transform-runtime": "^7.4.4", 41 | "@babel/preset-env": "^7.4.4", 42 | "@babel/register": "^7.4.4", 43 | "@rollup/plugin-babel": "^5.2.1", 44 | "@rollup/plugin-commonjs": "^15.1.0", 45 | "eslint": "^7.11.0", 46 | "eslint-config-standard": "^14.1.1", 47 | "eslint-plugin-import": "^2.22.1", 48 | "eslint-plugin-node": "^11.1.0", 49 | "eslint-plugin-promise": "^4.2.1", 50 | "eslint-plugin-standard": "^4.0.1", 51 | "mdast-util-from-markdown": "^0.8.4", 52 | "mocha": "^6.2.3", 53 | "rehype-stringify": "^8.0.0", 54 | "remark-parse": "^9.0.0", 55 | "remark-rehype": "^8.0.0", 56 | "remark-stringify": "^9.0.1", 57 | "rollup": "^2.32.0", 58 | "unified": "^9.2.0", 59 | "unist-util-select": "^3.0.4", 60 | "unist-util-visit": "^2.0.3" 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import commonjs from '@rollup/plugin-commonjs'; 2 | import babel from '@rollup/plugin-babel'; 3 | import pkg from './package.json'; 4 | 5 | const external = [ 6 | /@babel\/runtime/, 7 | /mdast-util-wiki-link/, 8 | /micromark-extension-wiki-link/, 9 | ]; 10 | 11 | const config = [ 12 | { 13 | input: 'src/index.js', 14 | output: { 15 | file: pkg.browser, 16 | format: 'esm' 17 | }, 18 | plugins: [ 19 | commonjs(), 20 | babel({ 21 | babelHelpers: 'runtime', 22 | exclude: ['node_modules/**'] 23 | }) 24 | ], 25 | external: external 26 | }, 27 | { 28 | input: 'src/index.js', 29 | output: [ 30 | { 31 | file: pkg.main, 32 | format: 'cjs' 33 | }, 34 | { 35 | file: pkg.module, 36 | format: 'es' 37 | } 38 | ], 39 | plugins: [ 40 | babel({ 41 | babelHelpers: 'runtime', 42 | exclude: ['node_modules/**'] 43 | }) 44 | ], 45 | external: external 46 | } 47 | ]; 48 | 49 | export default config; 50 | -------------------------------------------------------------------------------- /src/from-markdown.js: -------------------------------------------------------------------------------- 1 | function wikiLinkTransclusionFormat (extension) { 2 | const transclusionFormats = [ 3 | /\.jpe?g$/, /\.a?png$/, /\.webp$/, /\.avif$/, /\.gif$/, /\.svg$/, /\.bmp$/, /\.ico$/, /\.pdf$/ 4 | ] 5 | 6 | const supportedFormat = extension.match(transclusionFormats.filter(r => extension.match(r))[0])[0] 7 | const strippedExtension = extension.match(/\.[0-9a-z]{1,4}$/gi) 8 | 9 | if (!supportedFormat) return [false, strippedExtension && strippedExtension[0].replace('.', '')] 10 | 11 | return [true, supportedFormat.replace('.', '')] 12 | } 13 | 14 | function fromMarkdown (opts = {}) { 15 | const permalinks = opts.permalinks || [] 16 | const defaultPageResolver = (name) => [name.replace(/ /g, '-')] 17 | const pageResolver = opts.pageResolver || defaultPageResolver 18 | const newClassName = opts.newClassName || 'new' 19 | const wikiLinkClassName = opts.wikiLinkClassName || 'internal' 20 | const defaultHrefTemplate = (permalink) => { 21 | if (permalink.startsWith('#')) return permalink 22 | return `/${permalink}` 23 | } 24 | const hrefTemplate = opts.hrefTemplate || defaultHrefTemplate 25 | 26 | function enterWikiLink (token) { 27 | this.enter( 28 | { 29 | type: 'wikiLink', 30 | isType: token.isType ? token.isType : null, 31 | value: null, 32 | data: { 33 | alias: null, 34 | permalink: null, 35 | exists: null 36 | } 37 | }, 38 | token 39 | ) 40 | } 41 | 42 | function top (stack) { 43 | return stack[stack.length - 1] 44 | } 45 | 46 | function exitWikiLinkAlias (token) { 47 | const alias = this.sliceSerialize(token) 48 | const current = top(this.stack) 49 | current.data.alias = alias 50 | } 51 | 52 | function exitWikiLinkTarget (token) { 53 | const target = this.sliceSerialize(token) 54 | const current = top(this.stack) 55 | current.value = target 56 | } 57 | 58 | function exitWikiLink (token) { 59 | const wikiLink = this.exit(token) 60 | // if (opts.markdownFolder && wikiLink.value.includes(`${opts.markdownFolder}/`)) { 61 | // const [, ...value] = wikiLink.value.split(`${opts.markdownFolder}/`) 62 | // wikiLink.value = value 63 | // } 64 | const wikiLinkTransclusion = wikiLink.isType === 'transclusions' 65 | 66 | const pagePermalinks = pageResolver(wikiLink.value) 67 | let permalink = pagePermalinks.find((p) => { 68 | let heading = '' 69 | 70 | if (!wikiLinkTransclusion && p.match(/#/)) { 71 | [, heading] = p.split('#') 72 | } 73 | const link = heading ? p.replace(`#${heading}`, '') : p 74 | return permalinks.indexOf(link) !== -1 75 | }) 76 | const exists = permalink !== undefined 77 | if (!exists) { 78 | permalink = pagePermalinks[0] 79 | } 80 | const regex = /\/?index(?![\w\S])|\/?index(?=#)/g 81 | if (!wikiLinkTransclusion && permalink.match(regex)) { 82 | permalink = permalink.replace(regex, '') 83 | } 84 | 85 | let displayName 86 | let transclusionFormat 87 | 88 | if (wikiLinkTransclusion) { 89 | transclusionFormat = wikiLinkTransclusionFormat(wikiLink.value) 90 | if (!transclusionFormat[0]) { 91 | displayName = `Document type ${transclusionFormat[1] ? transclusionFormat[1].toUpperCase() : null} is not yet supported for transclusion` 92 | console.warn(displayName) 93 | wikiLink.data.hName = 'span' 94 | wikiLink.data.hChildren = [ 95 | { 96 | type: 'text', 97 | value: displayName 98 | } 99 | ] 100 | } else { 101 | const regex = new RegExp(`${transclusionFormat[1]}$`, 'g') 102 | displayName = wikiLink.value.replace(regex, '') 103 | 104 | if (transclusionFormat[1] === 'pdf') { 105 | wikiLink.data.hName = 'embed' 106 | } else { 107 | wikiLink.data.hName = 'img' 108 | } 109 | } 110 | } else { 111 | if (wikiLink.value.startsWith('#')) { 112 | displayName = wikiLink.value.replace('#', '') 113 | } else { 114 | displayName = wikiLink.value 115 | } 116 | wikiLink.data.hName = 'a' 117 | } 118 | 119 | if (wikiLink.data.alias && !wikiLinkTransclusion) { 120 | displayName = wikiLink.data.alias 121 | } 122 | 123 | let classNames = wikiLinkClassName 124 | if (!exists) { 125 | classNames += ' ' + newClassName 126 | } 127 | 128 | wikiLink.data.alias = displayName 129 | 130 | if (wikiLinkTransclusion && transclusionFormat[1] === 'pdf') { 131 | wikiLink.data.permalink = permalink + '#view=Fit' 132 | } else { 133 | wikiLink.data.permalink = permalink 134 | } 135 | wikiLink.data.exists = exists 136 | 137 | if (wikiLinkTransclusion) { 138 | if (!transclusionFormat[0]) { 139 | wikiLink.data.hProperties = { 140 | className: classNames + ' no-support', 141 | style: 'color:#fef08a;', 142 | src: hrefTemplate(permalink) 143 | } 144 | } else if (transclusionFormat[1] === 'pdf') { 145 | wikiLink.data.hProperties = { 146 | className: classNames, 147 | width: '100%', 148 | style: 'height:100vh;', 149 | type: 'application/pdf', 150 | src: hrefTemplate(permalink) + '#view=Fit' 151 | } 152 | } else { 153 | wikiLink.data.hProperties = { 154 | className: classNames, 155 | src: hrefTemplate(permalink) 156 | } 157 | } 158 | } else { 159 | wikiLink.data.hProperties = { 160 | className: classNames, 161 | href: hrefTemplate(permalink) 162 | } 163 | wikiLink.data.hChildren = [ 164 | { 165 | type: 'text', 166 | value: displayName 167 | } 168 | ] 169 | } 170 | } 171 | 172 | return { 173 | enter: { 174 | wikiLink: enterWikiLink 175 | }, 176 | exit: { 177 | wikiLinkTarget: exitWikiLinkTarget, 178 | wikiLinkAlias: exitWikiLinkAlias, 179 | wikiLink: exitWikiLink 180 | } 181 | } 182 | } 183 | 184 | export { fromMarkdown, wikiLinkTransclusionFormat } 185 | -------------------------------------------------------------------------------- /src/getFiles.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs') 2 | const path = require('path') 3 | 4 | const pipe = (...fns) => (x) => fns.reduce((v, f) => f(v), x) 5 | 6 | const flattenArray = (input) => 7 | input.reduce((acc, item) => [...acc, ...(Array.isArray(item) ? item : [item])], []) 8 | 9 | const map = (fn) => (input) => input.map(fn) 10 | 11 | const walkDir = (fullPath) => { 12 | return fs.statSync(fullPath).isFile() ? fullPath : getAllFilesRecursively(fullPath) 13 | } 14 | 15 | const pathJoinPrefix = (prefix) => (extraPath) => path.join(prefix, extraPath) 16 | 17 | const getAllFilesRecursively = (folder) => 18 | pipe(fs.readdirSync, map(pipe(pathJoinPrefix(folder), walkDir)), flattenArray)(folder) 19 | 20 | export function getFiles (type) { 21 | const prefixPaths = path.join(process.cwd(), type) 22 | const files = getAllFilesRecursively(prefixPaths) 23 | // Only want to return path and ignore root, replace is needed to work on Windows 24 | return files.map((file) => file.slice(prefixPaths.length + 1).replace(/\\/g, '/')) 25 | } 26 | -------------------------------------------------------------------------------- /src/html.js: -------------------------------------------------------------------------------- 1 | import { wikiLinkTransclusionFormat } from './from-markdown' 2 | 3 | function html (opts = {}) { 4 | const permalinks = opts.permalinks || [] 5 | const defaultPageResolver = (name) => { 6 | const image = wikiLinkTransclusionFormat(name)[1] 7 | return image ? [name] : [name.replace(/ /g, '_').toLowerCase()] 8 | } 9 | const pageResolver = opts.pageResolver || defaultPageResolver 10 | const newClassName = opts.newClassName || 'new' 11 | const wikiLinkClassName = opts.wikiLinkClassName || 'internal' 12 | const defaultHrefTemplate = (permalink) => `/${permalink}` 13 | const hrefTemplate = opts.hrefTemplate || defaultHrefTemplate 14 | 15 | function enterWikiLink () { 16 | let stack = this.getData('wikiLinkStack') 17 | if (!stack) this.setData('wikiLinkStack', (stack = [])) 18 | 19 | stack.push({}) 20 | } 21 | 22 | function top (stack) { 23 | return stack[stack.length - 1] 24 | } 25 | 26 | function exitWikiLinkAlias (token) { 27 | const alias = this.sliceSerialize(token) 28 | const current = top(this.getData('wikiLinkStack')) 29 | current.alias = alias 30 | } 31 | 32 | function exitWikiLinkTarget (token) { 33 | const target = this.sliceSerialize(token) 34 | const current = top(this.getData('wikiLinkStack')) 35 | current.target = target 36 | } 37 | 38 | function exitWikiLink () { 39 | const wikiLink = this.getData('wikiLinkStack').pop() 40 | const wikiLinkTransclusion = wikiLink.isType === 'transclusions' 41 | 42 | const pagePermalinks = pageResolver(wikiLink.target) 43 | let permalink = pagePermalinks.find(p => permalinks.indexOf(p) !== -1) 44 | const exists = permalink !== undefined 45 | if (!exists) { 46 | permalink = pagePermalinks[0] 47 | } 48 | let displayName = wikiLink.target 49 | 50 | if (wikiLink.alias) { 51 | displayName = wikiLink.alias 52 | } 53 | 54 | let classNames = wikiLinkClassName 55 | if (!exists) { 56 | classNames += ' ' + newClassName 57 | } 58 | 59 | const transclusionFormat = wikiLinkTransclusionFormat(wikiLink.value) 60 | 61 | if (wikiLinkTransclusion) { 62 | if (!transclusionFormat[0]) { 63 | this.raw(displayName) 64 | } else if (transclusionFormat[1] === 'pdf') { 65 | this.tag(``) 66 | } else { 67 | this.tag(`${displayName}`) 68 | } 69 | } else { 70 | this.tag('') 71 | this.raw(displayName) 72 | this.tag('') 73 | } 74 | } 75 | 76 | return { 77 | enter: { 78 | wikiLink: enterWikiLink 79 | }, 80 | exit: { 81 | wikiLinkTarget: exitWikiLinkTarget, 82 | wikiLinkAlias: exitWikiLinkAlias, 83 | wikiLink: exitWikiLink 84 | } 85 | } 86 | } 87 | 88 | export { html } 89 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import { syntax } from './syntax' 2 | import { toMarkdown } from 'mdast-util-wiki-link' 3 | import { fromMarkdown, wikiLinkTransclusionFormat } from './from-markdown' 4 | import { getFiles } from './getFiles' 5 | 6 | let warningIssued 7 | 8 | function wikiLinkPlugin (opts = { markdownFolder: '' }) { 9 | const data = this.data() 10 | 11 | function add (field, value) { 12 | if (data[field]) data[field].push(value) 13 | else data[field] = [value] 14 | } 15 | 16 | if (!warningIssued && 17 | ((this.Parser && 18 | this.Parser.prototype && 19 | this.Parser.prototype.blockTokenizers) || 20 | (this.Compiler && 21 | this.Compiler.prototype && 22 | this.Compiler.prototype.visitors))) { 23 | warningIssued = true 24 | console.warn( 25 | '[remark-wiki-link] Warning: please upgrade to remark 13 to use this plugin' 26 | ) 27 | } 28 | 29 | opts = { 30 | ...opts, 31 | aliasDivider: opts.aliasDivider ? opts.aliasDivider : '|', 32 | pageResolver: opts.pageResolver ? opts.pageResolver : (name) => { 33 | const image = wikiLinkTransclusionFormat(name)[1] 34 | let heading = '' 35 | if (!image && !name.startsWith('#') && name.match(/#/)) { 36 | [, heading] = name.split('#') 37 | name = name.replace(`#${heading}`, '') 38 | } else if (name.startsWith('#')) { 39 | name = name.toLowerCase() 40 | } 41 | if (opts.permalinks || opts.markdownFolder) { 42 | const url = opts.permalinks.find(p => p === name || (p.split('/').pop() === name && !opts.permalinks.includes(p.split('/').pop()))) 43 | if (url) { 44 | if (heading) return [`${url}#${heading.toLowerCase()}`.replace(/ /g, '-')] 45 | return image ? [url] : [url.replace(/ /g, '-')] 46 | } 47 | } 48 | return image ? [name] : [name.replace(/ /g, '-')] 49 | }, 50 | permalinks: opts.markdownFolder ? getFiles(opts.markdownFolder).map(file => file.replace(/\.mdx?$/, '')) : opts.permalinks 51 | } 52 | 53 | add('micromarkExtensions', syntax(opts)) 54 | add('fromMarkdownExtensions', fromMarkdown(opts)) 55 | add('toMarkdownExtensions', toMarkdown(opts)) 56 | } 57 | 58 | wikiLinkPlugin.wikiLinkPlugin = wikiLinkPlugin 59 | export default wikiLinkPlugin 60 | -------------------------------------------------------------------------------- /src/syntax.js: -------------------------------------------------------------------------------- 1 | import { html } from './html' 2 | 3 | const codes = { 4 | horizontalTab: -2, 5 | virtualSpace: -1, 6 | nul: 0, 7 | eof: null, 8 | space: 32 9 | } 10 | 11 | function markdownLineEndingOrSpace (code) { 12 | return code < codes.nul || code === codes.space 13 | } 14 | 15 | function markdownLineEnding (code) { 16 | return code < codes.horizontalTab 17 | } 18 | 19 | function wikiLink (opts = {}) { 20 | const aliasDivider = opts.aliasDivider || ':' 21 | 22 | const aliasMarker = aliasDivider 23 | const startMarker = '[[' 24 | const imageStartMarker = '![[' 25 | const endMarker = ']]' 26 | 27 | function tokenize (effects, ok, nok) { 28 | var data 29 | var alias 30 | 31 | var aliasCursor = 0 32 | var startMarkerCursor = 0 33 | var endMarkerCursor = 0 34 | 35 | return start 36 | 37 | function start (code) { 38 | if (code === startMarker.charCodeAt(startMarkerCursor)) { 39 | effects.enter('wikiLink') 40 | effects.enter('wikiLinkMarker') 41 | 42 | return consumeStart(code) 43 | } else if (code === imageStartMarker.charCodeAt(startMarkerCursor)) { 44 | effects.enter('wikiLink', { isType: 'transclusions' }) 45 | effects.enter('wikiLinkMarker', { isType: 'transclusions' }) 46 | 47 | return consumeStart(code) 48 | } else { 49 | return nok(code) 50 | } 51 | } 52 | 53 | function consumeStart (code) { 54 | if (startMarkerCursor === startMarker.length) { 55 | effects.exit('wikiLinkMarker') 56 | return consumeData(code) 57 | } 58 | 59 | if (code === startMarker.charCodeAt(startMarkerCursor) || code === imageStartMarker.charCodeAt(startMarkerCursor)) { 60 | effects.consume(code) 61 | if (code === 91) startMarkerCursor++ 62 | 63 | return consumeStart 64 | } else { 65 | return nok(code) 66 | } 67 | } 68 | 69 | function consumeData (code) { 70 | if (markdownLineEnding(code) || code === codes.eof) { 71 | return nok(code) 72 | } 73 | 74 | effects.enter('wikiLinkData') 75 | effects.enter('wikiLinkTarget') 76 | return consumeTarget(code) 77 | } 78 | 79 | function consumeTarget (code) { 80 | if (code === aliasMarker.charCodeAt(aliasCursor)) { 81 | if (!data) return nok(code) 82 | effects.exit('wikiLinkTarget') 83 | effects.enter('wikiLinkAliasMarker') 84 | return consumeAliasMarker(code) 85 | } 86 | 87 | if (code === endMarker.charCodeAt(endMarkerCursor)) { 88 | if (!data) return nok(code) 89 | effects.exit('wikiLinkTarget') 90 | effects.exit('wikiLinkData') 91 | effects.enter('wikiLinkMarker') 92 | return consumeEnd(code) 93 | } 94 | 95 | if (markdownLineEnding(code) || code === codes.eof) { 96 | return nok(code) 97 | } 98 | 99 | if (!markdownLineEndingOrSpace(code)) { 100 | data = true 101 | } 102 | 103 | effects.consume(code) 104 | 105 | return consumeTarget 106 | } 107 | 108 | function consumeAliasMarker (code) { 109 | if (aliasCursor === aliasMarker.length) { 110 | effects.exit('wikiLinkAliasMarker') 111 | effects.enter('wikiLinkAlias') 112 | return consumeAlias(code) 113 | } 114 | 115 | if (code !== aliasMarker.charCodeAt(aliasCursor)) { 116 | return nok(code) 117 | } 118 | 119 | effects.consume(code) 120 | aliasCursor++ 121 | 122 | return consumeAliasMarker 123 | } 124 | 125 | function consumeAlias (code) { 126 | if (code === endMarker.charCodeAt(endMarkerCursor)) { 127 | if (!alias) return nok(code) 128 | effects.exit('wikiLinkAlias') 129 | effects.exit('wikiLinkData') 130 | effects.enter('wikiLinkMarker') 131 | return consumeEnd(code) 132 | } 133 | 134 | if (markdownLineEnding(code) || code === codes.eof) { 135 | return nok(code) 136 | } 137 | 138 | if (!markdownLineEndingOrSpace(code)) { 139 | alias = true 140 | } 141 | 142 | effects.consume(code) 143 | 144 | return consumeAlias 145 | } 146 | 147 | function consumeEnd (code) { 148 | if (endMarkerCursor === endMarker.length) { 149 | effects.exit('wikiLinkMarker') 150 | effects.exit('wikiLink') 151 | return ok(code) 152 | } 153 | 154 | if (code !== endMarker.charCodeAt(endMarkerCursor)) { 155 | return nok(code) 156 | } 157 | 158 | effects.consume(code) 159 | endMarkerCursor++ 160 | 161 | return consumeEnd 162 | } 163 | } 164 | 165 | var call = { tokenize: tokenize } 166 | 167 | return { 168 | text: { 91: call, 33: call } // 91: left square bracket, 33: exclamation mark 169 | } 170 | } 171 | 172 | export { 173 | wikiLink as syntax, 174 | html 175 | } 176 | -------------------------------------------------------------------------------- /test/index_test.js: -------------------------------------------------------------------------------- 1 | const wikiLinkPlugin = require('..') 2 | const { wikiLinkPlugin: namedWikiLinkPlugin } = require('..') 3 | 4 | const assert = require('assert') 5 | const unified = require('unified') 6 | const markdown = require('remark-parse') 7 | const visit = require('unist-util-visit') 8 | const select = require('unist-util-select') 9 | const remark2markdown = require('remark-stringify') 10 | 11 | describe('remark-wiki-link-plus', () => { 12 | it('parses a wiki link that has a matching permalink', () => { 13 | const processor = unified() 14 | .use(markdown) 15 | .use(wikiLinkPlugin, { 16 | permalinks: ['test'] 17 | }) 18 | 19 | var ast = processor.parse('[[test]]') 20 | ast = processor.runSync(ast) 21 | 22 | visit(ast, 'wikiLink', (node) => { 23 | assert.equal(node.data.permalink, 'test') 24 | assert.equal(node.data.exists, true) 25 | assert.equal(node.data.hName, 'a') 26 | assert.equal(node.data.hProperties.className, 'internal') 27 | assert.equal(node.data.hProperties.href, '/test') 28 | assert.equal(node.data.hChildren[0].value, 'test') 29 | }) 30 | }) 31 | 32 | it('parses a wiki link that has a matching permalink (Case sensitive)', () => { 33 | const processor = unified() 34 | .use(markdown) 35 | .use(wikiLinkPlugin, { 36 | permalinks: ['Test'] 37 | }) 38 | 39 | var ast = processor.parse('[[Test]]') 40 | ast = processor.runSync(ast) 41 | 42 | visit(ast, 'wikiLink', (node) => { 43 | assert.equal(node.data.permalink, 'Test') 44 | assert.equal(node.data.exists, true) 45 | assert.equal(node.data.hName, 'a') 46 | assert.equal(node.data.hProperties.className, 'internal') 47 | assert.equal(node.data.hProperties.href, '/Test') 48 | assert.equal(node.data.hChildren[0].value, 'Test') 49 | }) 50 | }) 51 | 52 | it('parses a wiki link that has no matching permalink', () => { 53 | const processor = unified() 54 | .use(markdown) 55 | .use(wikiLinkPlugin, { 56 | permalinks: [] 57 | }) 58 | 59 | var ast = processor.parse('[[New Page]]') 60 | ast = processor.runSync(ast) 61 | 62 | visit(ast, 'wikiLink', (node) => { 63 | assert.equal(node.data.exists, false) 64 | assert.equal(node.data.permalink, 'New-Page') 65 | assert.equal(node.data.hName, 'a') 66 | assert.equal(node.data.hProperties.className, 'internal new') 67 | assert.equal(node.data.hProperties.href, '/New-Page') 68 | assert.equal(node.data.hChildren[0].value, 'New Page') 69 | }) 70 | }) 71 | 72 | it('handles wiki alias links with custom divider', () => { 73 | const processor = unified() 74 | .use(markdown) 75 | .use(wikiLinkPlugin, { 76 | permalinks: ['example/test'] 77 | }) 78 | 79 | var ast = processor.parse('[[example/test|custom text]]') 80 | ast = processor.runSync(ast) 81 | 82 | visit(ast, 'wikiLink', node => { 83 | assert.equal(node.data.exists, true) 84 | assert.equal(node.data.permalink, 'example/test') 85 | assert.equal(node.data.hName, 'a') 86 | assert.equal(node.data.alias, 'custom text') 87 | assert.equal(node.value, 'example/test') 88 | assert.equal(node.data.hProperties.className, 'internal') 89 | assert.equal(node.data.hProperties.href, '/example/test') 90 | assert.equal(node.data.hChildren[0].value, 'custom text') 91 | }) 92 | }) 93 | 94 | it('handles wiki links with heading', () => { 95 | const processor = unified() 96 | .use(markdown) 97 | .use(wikiLinkPlugin, { 98 | permalinks: ['example/test'] 99 | }) 100 | 101 | var ast = processor.parse('[[example/test#with heading]]') 102 | ast = processor.runSync(ast) 103 | 104 | visit(ast, 'wikiLink', node => { 105 | assert.equal(node.data.exists, true) 106 | assert.equal(node.data.permalink, 'example/test#with-heading') 107 | assert.equal(node.data.hName, 'a') 108 | assert.equal(node.data.hProperties.className, 'internal') 109 | assert.equal(node.data.hProperties.href, '/example/test#with-heading') 110 | assert.equal(node.data.hChildren[0].value, 'example/test#with heading') 111 | }) 112 | }) 113 | 114 | it('handles wiki links with heading (case insensitive)', () => { 115 | const processor = unified() 116 | .use(markdown) 117 | .use(wikiLinkPlugin, { 118 | permalinks: ['example/test'] 119 | }) 120 | 121 | var ast = processor.parse('[[example/test#With heading]]') 122 | ast = processor.runSync(ast) 123 | 124 | visit(ast, 'wikiLink', node => { 125 | assert.equal(node.data.exists, true) 126 | assert.equal(node.data.permalink, 'example/test#with-heading') 127 | assert.equal(node.data.hName, 'a') 128 | assert.equal(node.data.hProperties.className, 'internal') 129 | assert.equal(node.data.hProperties.href, '/example/test#with-heading') 130 | assert.equal(node.data.hChildren[0].value, 'example/test#With heading') 131 | }) 132 | }) 133 | 134 | it('handles wiki alias links with heading and custom divider', () => { 135 | const processor = unified() 136 | .use(markdown) 137 | .use(wikiLinkPlugin, { 138 | permalinks: ['example/test'] 139 | }) 140 | 141 | var ast = processor.parse('[[example/test#with heading|custom text]]') 142 | ast = processor.runSync(ast) 143 | 144 | visit(ast, 'wikiLink', node => { 145 | assert.equal(node.data.exists, true) 146 | assert.equal(node.data.permalink, 'example/test#with-heading') 147 | assert.equal(node.data.hName, 'a') 148 | assert.equal(node.data.hProperties.className, 'internal') 149 | assert.equal(node.data.hProperties.href, '/example/test#with-heading') 150 | assert.equal(node.data.hChildren[0].value, 'custom text') 151 | }) 152 | }) 153 | 154 | it('handles a wiki link heading within the page', () => { 155 | const processor = unified() 156 | .use(markdown) 157 | .use(wikiLinkPlugin) 158 | 159 | var ast = processor.parse('[[#Heading]]') 160 | ast = processor.runSync(ast) 161 | 162 | visit(ast, 'wikiLink', node => { 163 | assert.equal(node.data.permalink, '#heading') 164 | assert.equal(node.data.alias, 'Heading') 165 | assert.equal(node.data.hName, 'a') 166 | assert.equal(node.data.hProperties.className, 'internal new') 167 | assert.equal(node.data.hProperties.href, '#heading') 168 | assert.equal(node.data.hChildren[0].value, 'Heading') 169 | }) 170 | }) 171 | 172 | it('parses a wiki link that is an image', () => { 173 | const processor = unified() 174 | .use(markdown) 175 | .use(wikiLinkPlugin, { 176 | permalinks: ['images/Test image.png'] 177 | }) 178 | 179 | var ast = processor.parse('![[Test image.png]]') 180 | ast = processor.runSync(ast) 181 | 182 | visit(ast, 'wikiLink', (node) => { 183 | assert.equal(node.isType, 'transclusions') 184 | assert.equal(node.data.permalink, 'images/Test image.png') 185 | assert.equal(node.data.exists, true) 186 | assert.equal(node.data.hName, 'img') 187 | assert.equal(node.data.hProperties.className, 'internal') 188 | assert.equal(node.data.hProperties.src, '/images/Test image.png') 189 | }) 190 | }) 191 | 192 | it('parses a wiki link that is a PDF', () => { 193 | const processor = unified() 194 | .use(markdown) 195 | .use(wikiLinkPlugin, { 196 | permalinks: ['images/Test.pdf'] 197 | }) 198 | 199 | var ast = processor.parse('![[Test.pdf]]') 200 | ast = processor.runSync(ast) 201 | 202 | visit(ast, 'wikiLink', (node) => { 203 | assert.equal(node.isType, 'transclusions') 204 | assert.equal(node.data.permalink, 'images/Test.pdf#view=Fit') 205 | assert.equal(node.data.exists, true) 206 | assert.equal(node.data.hName, 'embed') 207 | assert.equal(node.data.hProperties.className, 'internal') 208 | assert.equal(node.data.hProperties.src, '/images/Test.pdf#view=Fit') 209 | }) 210 | }) 211 | 212 | it('displays warning for a wiki link that is not a supported image', () => { 213 | const processor = unified() 214 | .use(markdown) 215 | .use(wikiLinkPlugin, { 216 | permalinks: ['images/Test image.pxg'] 217 | }) 218 | 219 | var ast = processor.parse('![[Test image.pxg]]') 220 | ast = processor.runSync(ast) 221 | 222 | visit(ast, 'wikiLink', (node) => { 223 | assert.equal(node.isType, 'transclusions') 224 | assert.equal(node.data.permalink, 'images/Test image.pxg') 225 | assert.equal(node.data.exists, true) 226 | assert.equal(node.data.hName, 'span') 227 | assert.equal(node.data.hChildren[0].value, 'Document type PXG is not yet supported for transclusion') 228 | }) 229 | }) 230 | 231 | it('stringifies wiki links', () => { 232 | const processor = unified() 233 | .use(markdown, { gfm: true, footnotes: true, yaml: true }) 234 | .use(remark2markdown) 235 | .use(wikiLinkPlugin, { permalinks: ['wiki-link'] }) 236 | 237 | const stringified = processor.processSync('[[Wiki Link]]').contents.trim() 238 | assert.equal(stringified, '[[Wiki Link]]') 239 | }) 240 | 241 | it('stringifies aliased wiki links', () => { 242 | const processor = unified() 243 | .use(markdown, { gfm: true, footnotes: true, yaml: true }) 244 | .use(remark2markdown) 245 | .use(wikiLinkPlugin, { 246 | aliasDivider: ':' 247 | }) 248 | 249 | const stringified = processor.processSync('[[Real Page:Page Alias]]').contents.trim() 250 | assert.equal(stringified, '[[Real Page:Page Alias]]') 251 | }) 252 | 253 | context('configuration options', () => { 254 | it('uses pageResolver', () => { 255 | const identity = (name) => [name] 256 | 257 | const processor = unified() 258 | .use(markdown) 259 | .use(wikiLinkPlugin, { 260 | pageResolver: identity, 261 | permalinks: ['A Page'] 262 | }) 263 | 264 | var ast = processor.parse('[[A Page]]') 265 | ast = processor.runSync(ast) 266 | 267 | visit(ast, 'wikiLink', (node) => { 268 | assert.equal(node.data.exists, true) 269 | assert.equal(node.data.permalink, 'A Page') 270 | assert.equal(node.data.hProperties.href, '/A Page') 271 | }) 272 | }) 273 | 274 | it('uses newClassName', () => { 275 | const processor = unified() 276 | .use(markdown) 277 | .use(wikiLinkPlugin, { 278 | newClassName: 'new_page' 279 | }) 280 | 281 | var ast = processor.parse('[[A Page]]') 282 | ast = processor.runSync(ast) 283 | 284 | visit(ast, 'wikiLink', (node) => { 285 | assert.equal(node.data.hProperties.className, 'internal new_page') 286 | }) 287 | }) 288 | 289 | it('uses hrefTemplate', () => { 290 | const processor = unified() 291 | .use(markdown) 292 | .use(wikiLinkPlugin, { 293 | hrefTemplate: (permalink) => permalink 294 | }) 295 | 296 | var ast = processor.parse('[[A Page]]') 297 | ast = processor.runSync(ast) 298 | 299 | visit(ast, 'wikiLink', (node) => { 300 | assert.equal(node.data.hProperties.href, 'A-Page') 301 | }) 302 | }) 303 | 304 | it('uses wikiLinkClassName', () => { 305 | const processor = unified() 306 | .use(markdown) 307 | .use(wikiLinkPlugin, { 308 | wikiLinkClassName: 'wiki_link', 309 | permalinks: ['a-page'] 310 | }) 311 | 312 | var ast = processor.parse('[[a page]]') 313 | ast = processor.runSync(ast) 314 | 315 | visit(ast, 'wikiLink', (node) => { 316 | assert.equal(node.data.hProperties.className, 'wiki_link') 317 | }) 318 | }) 319 | }) 320 | 321 | context('open wiki links', () => { 322 | it('handles open wiki links', () => { 323 | const processor = unified() 324 | .use(markdown) 325 | .use(wikiLinkPlugin, { 326 | permalinks: [] 327 | }) 328 | 329 | var ast = processor.parse('t[[\nt') 330 | ast = processor.runSync(ast) 331 | 332 | assert.ok(!select.select('wikiLink', ast)) 333 | }) 334 | 335 | it('handles open wiki links at end of file', () => { 336 | const processor = unified() 337 | .use(markdown) 338 | .use(wikiLinkPlugin, { 339 | permalinks: [] 340 | }) 341 | 342 | var ast = processor.parse('t [[') 343 | ast = processor.runSync(ast) 344 | 345 | assert.ok(!select.select('wikiLink', ast)) 346 | }) 347 | 348 | it('handles open wiki links with partial data', () => { 349 | const processor = unified() 350 | .use(markdown) 351 | .use(wikiLinkPlugin, { 352 | permalinks: [] 353 | }) 354 | 355 | var ast = processor.parse('t [[tt\nt') 356 | ast = processor.runSync(ast) 357 | 358 | assert.ok(!select.select('wikiLink', ast)) 359 | }) 360 | 361 | it('handles open wiki links with partial alias divider', () => { 362 | const processor = unified() 363 | .use(markdown) 364 | .use(wikiLinkPlugin, { 365 | aliasDivider: '::', 366 | permalinks: [] 367 | }) 368 | 369 | var ast = processor.parse('[[t::\n') 370 | ast = processor.runSync(ast) 371 | 372 | assert.ok(!select.select('wikiLink', ast)) 373 | }) 374 | 375 | it('handles open wiki links with partial alias', () => { 376 | const processor = unified() 377 | .use(markdown) 378 | .use(wikiLinkPlugin, { 379 | permalinks: [] 380 | }) 381 | 382 | var ast = processor.parse('[[t|\n') 383 | ast = processor.runSync(ast) 384 | 385 | assert.ok(!select.select('wikiLink', ast)) 386 | }) 387 | }) 388 | 389 | it('exports the plugin with named exports', () => { 390 | assert.equal(wikiLinkPlugin, namedWikiLinkPlugin) 391 | }) 392 | }) 393 | --------------------------------------------------------------------------------