├── .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(`
`)
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 |
--------------------------------------------------------------------------------