├── publish.sh ├── .travis.yml ├── .gitignore ├── .tm_properties ├── config ├── rollup.config.umd.js ├── rollup.config.cjs.js ├── rollup.config.es.js ├── rollup.config.iife.js ├── rollup.config.browser.cjs.js ├── rollup.config.browser.es.js ├── rollup.config.browser.umd.js └── rollup.config.js ├── src ├── root-node.js ├── utilities.js ├── node.js ├── html-parser.js ├── rules.js ├── collapse-whitespace.js ├── turndown.js └── commonmark-rules.js ├── LICENSE ├── package.json ├── test ├── turndown-test.js └── index.html ├── index.html └── README.md /publish.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | npm version patch 3 | npm publish -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "node" 4 | - "6" 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dist 2 | lib 3 | node_modules 4 | npm-debug.log 5 | test/*browser.js 6 | -------------------------------------------------------------------------------- /.tm_properties: -------------------------------------------------------------------------------- 1 | [test/index.html] 2 | scopeAttributes = attr.keep-whitespace 3 | -------------------------------------------------------------------------------- /config/rollup.config.umd.js: -------------------------------------------------------------------------------- 1 | import config from './rollup.config' 2 | 3 | export default config({ 4 | output: { 5 | file: 'lib/turndown.umd.js', 6 | format: 'umd' 7 | } 8 | }) 9 | -------------------------------------------------------------------------------- /config/rollup.config.cjs.js: -------------------------------------------------------------------------------- 1 | import config from './rollup.config' 2 | 3 | export default config({ 4 | output: { 5 | file: 'lib/turndown.cjs.js', 6 | format: 'cjs' 7 | }, 8 | browser: false 9 | }) 10 | -------------------------------------------------------------------------------- /config/rollup.config.es.js: -------------------------------------------------------------------------------- 1 | import config from './rollup.config' 2 | 3 | export default config({ 4 | output: { 5 | file: 'lib/turndown.es.js', 6 | format: 'es' 7 | }, 8 | browser: false 9 | }) 10 | -------------------------------------------------------------------------------- /config/rollup.config.iife.js: -------------------------------------------------------------------------------- 1 | import config from './rollup.config' 2 | 3 | export default config({ 4 | output: { 5 | file: 'dist/turndown.js', 6 | format: 'iife' 7 | }, 8 | browser: true 9 | }) 10 | -------------------------------------------------------------------------------- /config/rollup.config.browser.cjs.js: -------------------------------------------------------------------------------- 1 | import config from './rollup.config' 2 | 3 | export default config({ 4 | output: { 5 | file: 'lib/turndown.browser.cjs.js', 6 | format: 'cjs' 7 | }, 8 | browser: true 9 | }) 10 | -------------------------------------------------------------------------------- /config/rollup.config.browser.es.js: -------------------------------------------------------------------------------- 1 | import config from './rollup.config' 2 | 3 | export default config({ 4 | output: { 5 | file: 'lib/turndown.browser.es.js', 6 | format: 'es' 7 | }, 8 | browser: true 9 | }) 10 | -------------------------------------------------------------------------------- /config/rollup.config.browser.umd.js: -------------------------------------------------------------------------------- 1 | import config from './rollup.config' 2 | 3 | export default config({ 4 | output: { 5 | file: 'lib/turndown.browser.umd.js', 6 | format: 'umd' 7 | }, 8 | browser: true 9 | }) 10 | -------------------------------------------------------------------------------- /config/rollup.config.js: -------------------------------------------------------------------------------- 1 | import commonjs from 'rollup-plugin-commonjs' 2 | import replace from 'rollup-plugin-replace' 3 | import resolve from 'rollup-plugin-node-resolve' 4 | 5 | export default function (config) { 6 | return { 7 | input: 'src/turndown.js', 8 | name: 'TurndownService', 9 | output: config.output, 10 | external: ['jsdom'], 11 | plugins: [ 12 | commonjs(), 13 | replace({ 'process.browser': JSON.stringify(!!config.browser) }), 14 | resolve() 15 | ] 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/root-node.js: -------------------------------------------------------------------------------- 1 | import collapseWhitespace from './collapse-whitespace' 2 | import HTMLParser from './html-parser' 3 | import { isBlock, isVoid } from './utilities' 4 | 5 | export default function RootNode (input) { 6 | var root 7 | if (typeof input === 'string') { 8 | var doc = htmlParser().parseFromString( 9 | // DOM parsers arrange elements in the
and . 10 | // Wrapping in a custom element ensures elements are reliably arranged in 11 | // a single element. 12 | 'Hello worldWorld
Hello worldWorld
Hello world
'), 'Hello world') 119 | }) 120 | 121 | test('keepReplacement can be customised', function (t) { 122 | t.plan(1) 123 | var turndownService = new TurndownService({ 124 | keepReplacement: function (content, node) { 125 | return '\n\n' + node.outerHTML + '\n\n' 126 | } 127 | }) 128 | turndownService.keep(['del', 'ins']) 129 | t.equal(turndownService.turndown( 130 | 'Hello worldWorld
Hello world
'), 'Hello world') 159 | }) 160 | 161 | test('remove elements are overridden by keep', function (t) { 162 | t.plan(1) 163 | var turndownService = new TurndownService() 164 | turndownService.keep(['del', 'ins']) 165 | turndownService.remove(['del', 'ins']) 166 | t.equal(turndownService.turndown( 167 | 'Hello worldWorld
". If the latter, it means the HTML will be rendered if the viewer supports HTML (which, in Joplin, it does).
197 | replacement = replacement.replace(/<(.+?)>/g, '<$1>');
198 | }
199 | } else if (node.nodeType === 1) {
200 | replacement = replacementForNode.call(self, node)
201 | }
202 |
203 | return join(output, replacement)
204 | }, '')
205 | }
206 |
207 | /**
208 | * Appends strings as each rule requires and trims the output
209 | * @private
210 | * @param {String} output The conversion output
211 | * @returns A trimmed version of the ouput
212 | * @type String
213 | */
214 |
215 | function postProcess (output) {
216 | var self = this
217 | this.rules.forEach(function (rule) {
218 | if (typeof rule.append === 'function') {
219 | output = join(output, rule.append(self.options))
220 | }
221 | })
222 |
223 | return output.replace(/^[\t\r\n]+/, '').replace(/[\t\r\n\s]+$/, '')
224 | }
225 |
226 | /**
227 | * Converts an element node to its Markdown equivalent
228 | * @private
229 | * @param {HTMLElement} node The node to convert
230 | * @returns A Markdown representation of the node
231 | * @type String
232 | */
233 |
234 | function replacementForNode (node) {
235 | var rule = this.rules.forNode(node)
236 | var content = process.call(this, node, rule.escapeContent ? rule.escapeContent() : 'auto')
237 | var whitespace = node.flankingWhitespace
238 | if (whitespace.leading || whitespace.trailing) content = content.trim()
239 | return (
240 | whitespace.leading +
241 | rule.replacement(content, node, this.options) +
242 | whitespace.trailing
243 | )
244 | }
245 |
246 | /**
247 | * Determines the new lines between the current output and the replacement
248 | * @private
249 | * @param {String} output The current conversion output
250 | * @param {String} replacement The string to append to the output
251 | * @returns The whitespace to separate the current output and the replacement
252 | * @type String
253 | */
254 |
255 | function separatingNewlines (output, replacement) {
256 | var newlines = [
257 | output.match(trailingNewLinesRegExp)[0],
258 | replacement.match(leadingNewLinesRegExp)[0]
259 | ].sort()
260 | var maxNewlines = newlines[newlines.length - 1]
261 | return maxNewlines.length < 2 ? maxNewlines : '\n\n'
262 | }
263 |
264 | function join (string1, string2) {
265 | var separator = separatingNewlines(string1, string2)
266 |
267 | // Remove trailing/leading newlines and replace with separator
268 | string1 = string1.replace(trailingNewLinesRegExp, '')
269 | string2 = string2.replace(leadingNewLinesRegExp, '')
270 |
271 | return string1 + separator + string2
272 | }
273 |
274 | /**
275 | * Determines whether an input can be converted
276 | * @private
277 | * @param {String|HTMLElement} input Describe this parameter
278 | * @returns Describe what it returns
279 | * @type String|Object|Array|Boolean|Number
280 | */
281 |
282 | function canConvert (input) {
283 | return (
284 | input != null && (
285 | typeof input === 'string' ||
286 | (input.nodeType && (
287 | input.nodeType === 1 || input.nodeType === 9 || input.nodeType === 11
288 | ))
289 | )
290 | )
291 | }
292 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Turndown
2 |
3 | [](https://travis-ci.org/domchristie/turndown)
4 |
5 | Convert HTML into Markdown with JavaScript.
6 |
7 | ## Modifications
8 |
9 | **This is a mod of the original turndown package for use with Joplin.** The following changes have been made:
10 |
11 | - Remove JavaScript code from links.
12 | - Prevent newlines inside link text.
13 | - Fixed ordered lists indentation when there are more than 9 items.
14 | - Added support for ` Hello Hello ` elements is as follows:
153 |
154 | ```js
155 | {
156 | filter: 'p',
157 | replacement: function (content) {
158 | return '\n\n' + content + '\n\n'
159 | }
160 | }
161 | ```
162 |
163 | The filter selects ` ` elements, and the replacement function returns the ` ` contents separated by two new lines.
164 |
165 | ### `filter` String|Array|Function
166 |
167 | The filter property determines whether or not an element should be replaced with the rule's `replacement`. DOM nodes can be selected simply using a tag name or an array of tag names:
168 |
169 | * `filter: 'p'` will select ` ` elements
170 | * `filter: ['em', 'i']` will select `` or `` elements
171 |
172 | Alternatively, the filter can be a function that returns a boolean depending on whether a given node should be replaced. The function is passed a DOM node as well as the `TurndownService` options. For example, the following rule selects `` elements (with an `href`) when the `linkStyle` option is `inlined`:
173 |
174 | ```js
175 | filter: function (node, options) {
176 | return (
177 | options.linkStyle === 'inlined' &&
178 | node.nodeName === 'A' &&
179 | node.getAttribute('href')
180 | )
181 | }
182 | ```
183 |
184 | ### `replacement` Function
185 |
186 | The replacement function determines how an element should be converted. It should return the Markdown string for a given node. The function is passed the node's content, the node itself, and the `TurndownService` options.
187 |
188 | The following rule shows how `` elements are converted:
189 |
190 | ```js
191 | rules.emphasis = {
192 | filter: ['em', 'i'],
193 |
194 | replacement: function (content, node, options) {
195 | return options.emDelimiter + content + options.emDelimiter
196 | }
197 | }
198 | ```
199 |
200 | ### Special Rules
201 |
202 | **Blank rule** determines how to handle blank elements. It overrides every rule (even those added via `addRule`). A node is blank if it only contains whitespace, and it's not an ``, `Hello world!
')
47 | ```
48 |
49 | Turndown also accepts DOM nodes as input (either element nodes, document nodes, or document fragment nodes):
50 |
51 | ```js
52 | var markdown = turndownService.turndown(document.getElementById('content'))
53 | ```
54 |
55 | ## Options
56 |
57 | Options can be passed in to the constructor on instantiation.
58 |
59 | | Option | Valid values | Default |
60 | | :-------------------- | :------------ | :------ |
61 | | `headingStyle` | `setext` or `atx` | `setext` |
62 | | `hr` | Any [Thematic break](http://spec.commonmark.org/0.27/#thematic-breaks) | `* * *` |
63 | | `bulletListMarker` | `-`, `+`, or `*` | `*` |
64 | | `codeBlockStyle` | `indented` or `fenced` | `indented` |
65 | | `fence` | ` ``` ` or `~~~` | ` ``` ` |
66 | | `emDelimiter` | `_` or `*` | `_` |
67 | | `strongDelimiter` | `**` or `__` | `**` |
68 | | `linkStyle` | `inlined` or `referenced` | `inlined` |
69 | | `linkReferenceStyle` | `full`, `collapsed`, or `shortcut` | `full` |
70 |
71 | ### Advanced Options
72 |
73 | | Option | Valid values | Default |
74 | | :-------------------- | :------------ | :------ |
75 | | `blankReplacement` | rule replacement function | See **Special Rules** below |
76 | | `keepReplacement` | rule replacement function | See **Special Rules** below |
77 | | `defaultReplacement` | rule replacement function | See **Special Rules** below |
78 |
79 | ## Methods
80 |
81 | ### `addRule(key, rule)`
82 |
83 | The `key` parameter is a unique name for the rule for easy reference. Example:
84 |
85 | ```js
86 | turndownService.addRule('strikethrough', {
87 | filter: ['del', 's', 'strike'],
88 | replacement: function (content) {
89 | return '~' + content + '~'
90 | }
91 | })
92 | ```
93 |
94 | `addRule` returns the `TurndownService` instance for chaining.
95 |
96 | See **Extending with Rules** below.
97 |
98 | ### `keep(filter)`
99 |
100 | Determines which elements are to be kept and rendered as HTML. By default, Turndown does not keep any elements. The filter parameter works like a rule filter (see section on filters belows). Example:
101 |
102 | ```js
103 | turndownService.keep(['del', 'ins'])
104 | turndownService.turndown('worldWorldworldWorld'
105 | ```
106 |
107 | This will render `` and `` elements as HTML when converted.
108 |
109 | `keep` can be called multiple times, with the newly added keep filters taking precedence over older ones. Keep filters will be overridden by the standard CommonMark rules and any added rules. To keep elements that are normally handled by those rules, add a rule with the desired behaviour.
110 |
111 | `keep` returns the `TurndownService` instance for chaining.
112 |
113 | ### `remove(filter)`
114 |
115 | Determines which elements are to be removed altogether i.e. converted to an empty string. By default, Turndown does not remove any elements. The filter parameter works like a rule filter (see section on filters belows). Example:
116 |
117 | ```js
118 | turndownService.remove('del')
119 | turndownService.turndown('worldWorld` elements (and contents).
123 |
124 | `remove` can be called multiple times, with the newly added remove filters taking precedence over older ones. Remove filters will be overridden by the keep filters, standard CommonMark rules, and any added rules. To remove elements that are normally handled by those rules, add a rule with the desired behaviour.
125 |
126 | `remove` returns the `TurndownService` instance for chaining.
127 |
128 | ### `use(plugin|array)`
129 |
130 | Use a plugin, or an array of plugins. Example:
131 |
132 | ```js
133 | // Import plugins from turndown-plugin-gfm
134 | var turndownPluginGfm = require('turndown-plugin-gfm')
135 | var gfm = turndownPluginGfm.gfm
136 | var tables = turndownPluginGfm.tables
137 | var strikethrough = turndownPluginGfm.strikethrough
138 |
139 | // Use the gfm plugin
140 | turndownService.use(gfm)
141 |
142 | // Use the table and strikethrough plugins only
143 | turndownService.use([tables, strikethrough])
144 | ```
145 |
146 | `use` returns the `TurndownService` instance for chaining.
147 |
148 | See **Plugins** below.
149 |
150 | ## Extending with Rules
151 |
152 | Turndown can be extended by adding **rules**. A rule is a plain JavaScript object with `filter` and `replacement` properties. For example, the rule for converting ``,` ` or a void element. Its behaviour can be customised using the `blankReplacement` option.
203 |
204 | **Keep rules** determine how to handle the elements that should not be converted, i.e. rendered as HTML in the Markdown output. By default, no elements are kept. Block-level elements will be separated from surrounding content by blank lines. Its behaviour can be customised using the `keepReplacement` option.
205 |
206 | **Remove rules** determine which elements to remove altogether. By default, no elements are removed.
207 |
208 | **Default rule** handles nodes which are not recognised by any other rule. By default, it outputs the node's text content (separated by blank lines if it is a block-level element). Its behaviour can be customised with the `defaultReplacement` option.
209 |
210 | ### Rule Precedence
211 |
212 | Turndown iterates over the set of rules, and picks the first one that matches the `filter`. The following list describes the order of precedence:
213 |
214 | 1. Blank rule
215 | 2. Added rules (optional)
216 | 3. Commonmark rules
217 | 4. Keep rules
218 | 5. Remove rules
219 | 6. Default rule
220 |
221 | ## Plugins
222 |
223 | The plugin API provides a convenient way for developers to apply multiple extensions. A plugin is just a function that is called with the `TurndownService` instance.
224 |
225 | ## License
226 |
227 | turndown is copyright © 2017+ Dom Christie and released under the MIT license.
228 |
--------------------------------------------------------------------------------
/src/commonmark-rules.js:
--------------------------------------------------------------------------------
1 | import { repeat } from './utilities'
2 | const Entities = require('html-entities').AllHtmlEntities;
3 | const htmlentities = (new Entities()).encode;
4 | const css = require('css');
5 |
6 | var rules = {}
7 |
8 | rules.paragraph = {
9 | filter: 'p',
10 |
11 | replacement: function (content) {
12 | return '\n\n' + content + '\n\n'
13 | }
14 | }
15 |
16 | rules.lineBreak = {
17 | filter: 'br',
18 |
19 | replacement: function (content, node, options) {
20 | return options.br + '\n'
21 | }
22 | }
23 |
24 | rules.heading = {
25 | filter: ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'],
26 |
27 | replacement: function (content, node, options) {
28 | var hLevel = Number(node.nodeName.charAt(1))
29 |
30 | if (options.headingStyle === 'setext' && hLevel < 3) {
31 | var underline = repeat((hLevel === 1 ? '=' : '-'), content.length)
32 | return (
33 | '\n\n' + content + '\n' + underline + '\n\n'
34 | )
35 | } else {
36 | return '\n\n' + repeat('#', hLevel) + ' ' + content + '\n\n'
37 | }
38 | }
39 | }
40 |
41 | rules.blockquote = {
42 | filter: 'blockquote',
43 |
44 | replacement: function (content) {
45 | content = content.replace(/^\n+|\n+$/g, '')
46 | content = content.replace(/^/gm, '> ')
47 | return '\n\n' + content + '\n\n'
48 | }
49 | }
50 |
51 | rules.list = {
52 | filter: ['ul', 'ol'],
53 |
54 | replacement: function (content, node) {
55 | var parent = node.parentNode
56 | if (parent.nodeName === 'LI' && parent.lastElementChild === node) {
57 | return '\n' + content
58 | } else {
59 | return '\n\n' + content + '\n\n'
60 | }
61 | }
62 | }
63 |
64 | rules.listItem = {
65 | filter: 'li',
66 |
67 | replacement: function (content, node, options) {
68 | const joplinCheckbox = joplinCheckboxInfo(node);
69 |
70 | content = content
71 | .replace(/^\n+/, '') // remove leading newlines
72 | .replace(/\n+$/, '\n') // replace trailing newlines with just a single one
73 |
74 | if (joplinCheckbox) {
75 | return '- [' + (joplinCheckbox.checked ? 'x' : ' ') + '] ' + content;
76 | } else {
77 | content = content.replace(/\n/gm, '\n ') // indent
78 | var prefix = options.bulletListMarker + ' '
79 | var parent = node.parentNode
80 | if (parent.nodeName === 'OL') {
81 | var start = parent.getAttribute('start')
82 | var index = Array.prototype.indexOf.call(parent.children, node)
83 | var indexStr = (start ? Number(start) + index : index + 1) + ''
84 | // The content of the line that contains the bullet must align wih the following lines.
85 | //
86 | // i.e it should be:
87 | //
88 | // 9. my content
89 | // second line
90 | // 10. next one
91 | // second line
92 | //
93 | // But not:
94 | //
95 | // 9. my content
96 | // second line
97 | // 10. next one
98 | // second line
99 | //
100 | prefix = indexStr + '.' + ' '.repeat(3 - indexStr.length)
101 | }
102 | return (
103 | prefix + content + (node.nextSibling && !/\n$/.test(content) ? '\n' : '')
104 | )
105 | }
106 | }
107 | }
108 |
109 | // To handle code that is presented as below (see https://github.com/laurent22/joplin/issues/573)
110 | //
111 | //
112 | //
116 | function isCodeBlockSpecialCase1(node) {
117 | const parent = node.parentNode
118 | return parent.classList.contains('code') && parent.nodeName === 'TD' && node.nodeName === 'PRE'
119 | }
120 |
121 | // To handle PRE tags that have a monospace font family. In that case
122 | // we assume it is a code block.
123 | function isCodeBlockSpecialCase2(node) {
124 | if (node.nodeName !== 'PRE') return false;
125 |
126 | const style = node.getAttribute('style');
127 | if (!style) return false;
128 | const o = css.parse('pre {' + style + '}');
129 | if (!o.stylesheet.rules.length) return;
130 | const fontFamily = o.stylesheet.rules[0].declarations.find(d => d.property.toLowerCase() === 'font-family');
131 | const isMonospace = fontFamily.value.split(',').map(e => e.trim().toLowerCase()).indexOf('monospace') >= 0;
132 | return isMonospace;
133 | }
134 |
135 | rules.indentedCodeBlock = {
136 | filter: function (node, options) {
137 | if (options.codeBlockStyle !== 'indented') return false
138 | if (isCodeBlockSpecialCase1(node) || isCodeBlockSpecialCase2(node)) return true
139 |
140 | return (
141 | node.nodeName === 'PRE' &&
142 | node.firstChild &&
143 | node.firstChild.nodeName === 'CODE'
144 | )
145 | },
146 |
147 | replacement: function (content, node, options) {
148 | const handledNode = isCodeBlockSpecialCase1(node) ? node : node.firstChild
149 |
150 | return (
151 | '\n\n ' +
152 | handledNode.textContent.replace(/\n/g, '\n ') +
153 | '\n\n'
154 | )
155 | }
156 | }
157 |
158 | rules.fencedCodeBlock = {
159 | filter: function (node, options) {
160 | if (options.codeBlockStyle !== 'fenced') return false;
161 | if (isCodeBlockSpecialCase1(node) || isCodeBlockSpecialCase2(node)) return true
162 |
163 | return (
164 | node.nodeName === 'PRE' &&
165 | node.firstChild &&
166 | node.firstChild.nodeName === 'CODE'
167 | )
168 | },
169 |
170 | replacement: function (content, node, options) {
171 | let handledNode = node.firstChild;
172 | if (isCodeBlockSpecialCase1(node) || isCodeBlockSpecialCase2(node)) handledNode = node;
173 |
174 | var className = handledNode.className || ''
175 | var language = (className.match(/language-(\S+)/) || [null, ''])[1]
176 |
177 | return (
178 | '\n\n' + options.fence + language + '\n' +
179 | handledNode.textContent +
180 | '\n' + options.fence + '\n\n'
181 | )
182 | }
183 | }
184 |
185 | rules.horizontalRule = {
186 | filter: 'hr',
187 |
188 | replacement: function (content, node, options) {
189 | return '\n\n' + options.hr + '\n\n'
190 | }
191 | }
192 |
193 | function filterLinkContent (content) {
194 | return content.trim().replace(/[\n\r]+/g, '
113 | // def ma_fonction
114 | //
115 | //
')
195 | }
196 |
197 | function filterLinkHref (href) {
198 | if (!href) return ''
199 | href = href.trim()
200 | if (href.toLowerCase().indexOf('javascript:') === 0) return '' // We don't want to keep js code in the markdown
201 | // Replace the spaces with %20 because otherwise they can cause problems for some
202 | // renderer and space is not a valid URL character anyway.
203 | href = href.replace(/ /g, '%20');
204 | return href
205 | }
206 |
207 | function getNamedAnchorFromLink(node, options) {
208 | var id = node.getAttribute('id')
209 | if (!id) id = node.getAttribute('name')
210 | if (id) id = id.trim();
211 |
212 | if (id && options.anchorNames.indexOf(id.toLowerCase()) >= 0) {
213 | return '';
214 | } else {
215 | return '';
216 | }
217 | }
218 |
219 | rules.inlineLink = {
220 | filter: function (node, options) {
221 | return (
222 | options.linkStyle === 'inlined' &&
223 | node.nodeName === 'A' &&
224 | (node.getAttribute('href') || node.getAttribute('name') || node.getAttribute('id'))
225 | )
226 | },
227 |
228 | replacement: function (content, node, options) {
229 | var href = filterLinkHref(node.getAttribute('href'))
230 | if (!href) {
231 | return getNamedAnchorFromLink(node, options) + filterLinkContent(content)
232 | } else {
233 | var title = node.title ? ' "' + node.title + '"' : ''
234 | if (!href) title = ''
235 | return getNamedAnchorFromLink(node, options) + '[' + filterLinkContent(content) + '](' + href + title + ')'
236 | }
237 | }
238 | }
239 |
240 | // Normally a named anchor would be but
241 | // you can also find Something so the
242 | // rule below handle this.
243 | // Fixes https://github.com/laurent22/joplin/issues/1876
244 | rules.otherNamedAnchors = {
245 | filter: function (node, options) {
246 | return !!getNamedAnchorFromLink(node, options);
247 | },
248 |
249 | replacement: function (content, node, options) {
250 | return getNamedAnchorFromLink(node, options) + content;
251 | }
252 | }
253 |
254 | rules.referenceLink = {
255 | filter: function (node, options) {
256 | return (
257 | options.linkStyle === 'referenced' &&
258 | node.nodeName === 'A' &&
259 | node.getAttribute('href')
260 | )
261 | },
262 |
263 | replacement: function (content, node, options) {
264 | var href = filterLinkHref(node.getAttribute('href'))
265 | var title = node.title ? ' "' + node.title + '"' : ''
266 | if (!href) title = ''
267 | var replacement
268 | var reference
269 |
270 | content = filterLinkContent(content)
271 |
272 | switch (options.linkReferenceStyle) {
273 | case 'collapsed':
274 | replacement = '[' + content + '][]'
275 | reference = '[' + content + ']: ' + href + title
276 | break
277 | case 'shortcut':
278 | replacement = '[' + content + ']'
279 | reference = '[' + content + ']: ' + href + title
280 | break
281 | default:
282 | var id = this.references.length + 1
283 | replacement = '[' + content + '][' + id + ']'
284 | reference = '[' + id + ']: ' + href + title
285 | }
286 |
287 | this.references.push(reference)
288 | return replacement
289 | },
290 |
291 | references: [],
292 |
293 | append: function (options) {
294 | var references = ''
295 | if (this.references.length) {
296 | references = '\n\n' + this.references.join('\n') + '\n\n'
297 | this.references = [] // Reset references
298 | }
299 | return references
300 | }
301 | }
302 |
303 | rules.emphasis = {
304 | filter: ['em', 'i'],
305 |
306 | replacement: function (content, node, options) {
307 | if (!content.trim()) return ''
308 | return options.emDelimiter + content + options.emDelimiter
309 | }
310 | }
311 |
312 | rules.strong = {
313 | filter: ['strong', 'b'],
314 |
315 | replacement: function (content, node, options) {
316 | if (!content.trim()) return ''
317 | return options.strongDelimiter + content + options.strongDelimiter
318 | }
319 | }
320 |
321 | rules.code = {
322 | filter: function (node) {
323 | var hasSiblings = node.previousSibling || node.nextSibling
324 | var isCodeBlock = node.parentNode.nodeName === 'PRE' && !hasSiblings
325 |
326 | return node.nodeName === 'CODE' && !isCodeBlock
327 | },
328 |
329 | replacement: function (content) {
330 | if (!content.trim()) return ''
331 |
332 | var delimiter = '`'
333 | var leadingSpace = ''
334 | var trailingSpace = ''
335 | var matches = content.match(/`+/gm)
336 | if (matches) {
337 | if (/^`/.test(content)) leadingSpace = ' '
338 | if (/`$/.test(content)) trailingSpace = ' '
339 | while (matches.indexOf(delimiter) !== -1) delimiter = delimiter + '`'
340 | }
341 |
342 | return delimiter + leadingSpace + content + trailingSpace + delimiter
343 | }
344 | }
345 |
346 | function imageMarkdownFromNode(node) {
347 | var alt = node.alt || ''
348 | var src = node.getAttribute('src') || ''
349 | var title = node.title || ''
350 | var titlePart = title ? ' "' + title + '"' : ''
351 | return src ? '![' + alt.replace(/([[\]])/g, '\\$1') + ']' + '(' + src + titlePart + ')' : ''
352 | }
353 |
354 | function imageUrlFromSource(node) {
355 | // Format of srcset can be:
356 | // srcset="kitten.png"
357 | // or:
358 | // srcset="kitten.png, kitten@2X.png 2x"
359 |
360 | let src = node.getAttribute('srcset');
361 | if (!src) src = node.getAttribute('data-srcset');
362 | if (!src) return '';
363 |
364 | const s = src.split(',');
365 | if (!s.length) return '';
366 | src = s[0];
367 |
368 | src = src.split(' ');
369 | return src[0];
370 | }
371 |
372 | rules.image = {
373 | filter: 'img',
374 |
375 | replacement: function (content, node) {
376 | return imageMarkdownFromNode(node);
377 | }
378 | }
379 |
380 | rules.picture = {
381 | filter: 'picture',
382 |
383 | replacement: function (content, node) {
384 | if (!node.childNodes) return '';
385 |
386 | let firstSource = null;
387 | let firstImg = null;
388 |
389 | for (let i = 0; i < node.childNodes.length; i++) {
390 | const child = node.childNodes[i];
391 |
392 | if (child.nodeName === 'SOURCE' && !firstSource) firstSource = child;
393 | if (child.nodeName === 'IMG') firstImg = child;
394 | }
395 |
396 | if (firstImg && firstImg.getAttribute('src')) {
397 | return imageMarkdownFromNode(firstImg);
398 | } else if (firstSource) {
399 | // A