From e00c32c9ba5c7df9c3bd0b357fd45aae333ab1c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20=C4=8C=C3=AD=C5=BEek?= Date: Thu, 5 Dec 2019 01:40:34 +0100 Subject: [PATCH] Add linkTitle syntas. Change the scope of code span escaping and its metadata behavior. Cleanup. Improve docs. --- .babelrc | 5 -- .eslintignore | 1 + README.md | 62 ++++++++++++-- package-lock.json | 30 +++---- package.json | 2 +- rollup.config.js | 17 +++- src/GfmEscape.js | 11 ++- src/Syntax.js | 4 +- src/defaultSetup.js | 7 +- src/replaces/codeSpanReplace.js | 40 ++++++--- .../extAutolink/ExtWebAutolinkTransformers.js | 1 - src/replaces/extAutolinkReplace.js | 22 ++--- src/replaces/linkDestinationReplace.js | 13 +++ .../linkDestinationSpecialsReplace.js | 8 -- src/replaces/linkTitleReplace.js | 85 +++++++++++++++++++ src/syntax/CodeSpanSyntax.js | 4 + src/syntax/LinkTitleSyntax.js | 17 ++++ src/utils/applyProcessors.js | 3 + ...yPostprocessor.js => wrapPostprocessor.js} | 2 +- test/behavior/itBacktranslates.js | 8 ++ test/code-span.spec.js | 44 ++++++---- test/link-destination.spec.js | 1 + test/link-title.spec.js | 42 +++++++++ 23 files changed, 339 insertions(+), 90 deletions(-) delete mode 100644 .babelrc create mode 100644 src/replaces/linkDestinationReplace.js delete mode 100644 src/replaces/linkDestinationSpecialsReplace.js create mode 100644 src/replaces/linkTitleReplace.js create mode 100644 src/syntax/LinkTitleSyntax.js create mode 100644 src/utils/applyProcessors.js rename src/utils/{applyPostprocessor.js => wrapPostprocessor.js} (69%) create mode 100644 test/link-title.spec.js diff --git a/.babelrc b/.babelrc deleted file mode 100644 index c7d754d..0000000 --- a/.babelrc +++ /dev/null @@ -1,5 +0,0 @@ -{ - "presets": [ - "@babel/env" - ] -} diff --git a/.eslintignore b/.eslintignore index b70b9b0..c1aacdb 100644 --- a/.eslintignore +++ b/.eslintignore @@ -1,3 +1,4 @@ dist tools/output test/mytest* +test/browser/* diff --git a/README.md b/README.md index b77027b..814dac1 100644 --- a/README.md +++ b/README.md @@ -73,11 +73,16 @@ The current full options are: breakWww: false, breaker: '', allowedTransformations: [ 'entities', 'commonmark' ], - allowAddHttpScheme: false + allowAddHttpScheme: false, + inImage: false, }, table: true, // default false emphasisNonDelimiters: { // default true - maxIntrawordUnderscoreRun: false + maxIntrawordUnderscoreRun: false, + }, + linkTitle: { // default true + delimiters: [ '"', '\'', '()' ], + alwaysEscapeDelimiters: [], }, } ``` @@ -93,10 +98,12 @@ The predefined syntaxes are available as members of `GfmEscape.Syntax`: the `isEncodable(input)` and `wouldBeUnaltered(input)` methods on the `Syntax.cmAutolink` object. - `codeSpan`: text rendered `` `here` ``. +- `linkTitle`: text rendered `[text](destination "here")` or + `[text](destination 'here')` or `[text](destination (here))`. `input`: the string to escape. Please note that correct escaping is currently only guaranteed when the input is trimmed and normalized in terms of whitespace. -The library does not perform whitespace normalizing on its own, as it is often +The library does not perfos qrm whitespace normalizing on its own, as it is often ensured by the source's origin, e.g. `textContent` of a normalized HTML DOM. Manual normalizing can be done with `input.trim().replace(/[ \t\n\r]+/g, ' ')`. If it is intended to keep the source somewhat organized in lines, the minimum @@ -110,19 +117,41 @@ no defaults, i.e. they are falsy by default. The following contexts are availabl ```js { inLink: true, // indicates suppressing nested links + inImage: true, // similar to inLink for ![this image text](img.png) inTable: true, // indicates extra escaping of table contents } ``` When escaping, `metadata` is extra input-output parameter that collects metadata about the actual escaping. Currently `metadata` are used for -`codeSpan` syntax, where two output parameters `delimiter` and `space` are passed: +`codeSpan` syntax and `linkTitle` syntax. ```js const escaper = new GfmEscape({ table: true }, GfmEscape.Syntax.codeSpan); -const x = {}; +const x = {}; // not necessary as the surrounding delimiter is always '`' const context = { inTable: true }; -const output = escaper.escape('`array|string`', context, x); -console.log(`${x.delimiter}${x.space}${output}${x.space}${x.delimiter}`); -// `` `array\|string` `` +const escaped = escaper.escape('`array|string`', context, x); +console.log(`\`${escaped}\``); // `` `array\|string` `` +console.log(`${x.extraBacktickString.length} backtickts and ${x.extraSpace.length} spaces added.`); +// 1 backticks and 1 spaces added. + +const linkTitleEscaper = new GfmEscape({}, GfmEscape.Syntax.linkTitle); +const x = {}; // needed as we let GfmEscape decide the surrounding delimiter +let escaped = escaper.escape('cool "link \'title\'"', context, x); +console.log(`${x.startDelimiter}${escaped}${x.endDelimiter}`); +// (cool "link 'title'") + +escaped = escaper.escape('average link title', context, x); +console.log(`${x.startDelimiter}${escaped}${x.endDelimiter}`); +// "average link title" + +const rigidLinkTitleEscaper = new GfmEscape({ + linkTitle: { + delimiters: '"', + } +}, GfmEscape.Syntax.linkTitle); +// metadata not necessary, as the surronding delimiter will be always '"' +escaped = escaper.escape('cool "link \'title\'"'); +console.log(`"${escaped}"`); +// "cool \"link 'title'\"" ``` #### Escaping options: `strikethrough` @@ -167,6 +196,10 @@ Suboptions: - `allowAddHttpScheme`: add `http://` scheme when a transformation needs it to work. E.g. `*www.orchi.tech,*` would become `\*,\*` with the `commonmark` transformation. +- `inImage`: suggest if extended autolink treatment should be applied within + image text. Although the CommonMark spec says links are interpreted and just + the stripped plain text part renders to the `alt` attribute, cmark-gfm actually + does not do it for extended autolinks, so the default is false. _How to choose the options_: 1. Consider rendering details of the target Markdown flavor. Backtranslation @@ -195,7 +228,7 @@ not to escape them. E.g. in `My account is joe_average.`, the underscore stays unescaped as `joe_average`, not ~~`joe\_average`~~. Suboptions: -* `maxIntrawordUnderscoreRun`: if defined, it sets the maximum length of intraword +- `maxIntrawordUnderscoreRun`: if defined, it sets the maximum length of intraword underscores to be kept as is. E.g. for `1` and input `joe_average or joe__average`, the output would be `joe_average or joe\_\_average`. This is helpful for some renderers like Redcarpet. Defaults to `undefined`. @@ -206,6 +239,16 @@ Defaults to `false`, i.e. table pipes are not escaped. If enabled, rendering of delimiter rows is suppressed by escaping its pipes and all pipes are escaped when in table context. +#### Escaping options: `linkTitle` + +Suboptions: +- `delimiters`: array of allowed delimiter to be chosen from or a single delimiter. + Delimiters are `"`, `'` and `()`. When more delimiters are allowed, GfmEscape picks + the least interferring one. The picked delimiter is returned in metadata, as shown + in the example above. +- `alwaysEscapeDelimiters`: array of delimiters that are always escaped. + + ## GFM escaping details Terminology: @@ -255,6 +298,7 @@ implementation of GFM Spec, we have found a few interesting details... - `cmark_gfm-005`: Backslash escape in link destination, e.g. `[foo](http://orchi.tech/foo\_bar)` does not prevent entity reference from interpreting in rendered HTML. We use entity encoding instead, i.e. `&`. + The same applies to link titles. ## TODO diff --git a/package-lock.json b/package-lock.json index 107cef8..3fba8fa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1056,9 +1056,9 @@ "dev": true }, "caniuse-lite": { - "version": "1.0.30001013", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001013.tgz", - "integrity": "sha512-hOAXaWKuq/UVFgYawxIOdPdyMQdYcwOCDOjnZcKn7wCgFUrhP7smuNZjGLuJlPSgE6aRA4cRJ+bGSrhtEt7ZAg==", + "version": "1.0.30001015", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001015.tgz", + "integrity": "sha512-/xL2AbW/XWHNu1gnIrO8UitBGoFthcsDgU9VLK1/dpsoxbaD5LscHozKze05R6WLsBvLhqv78dAPozMFQBYLbQ==", "dev": true }, "caseless": { @@ -1311,9 +1311,9 @@ } }, "electron-to-chromium": { - "version": "1.3.321", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.3.321.tgz", - "integrity": "sha512-jJy/BZK2s2eAjMPXVMSaCmo7/pSY2aKkfQ+LoAb5Wk39qAhyP9r8KU74c4qTgr9cD/lPUhJgReZxxqU0n5puog==", + "version": "1.3.322", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.3.322.tgz", + "integrity": "sha512-Tc8JQEfGQ1MzfSzI/bTlSr7btJv/FFO7Yh6tanqVmIWOuNCu6/D1MilIEgLtmWqIrsv+o4IjpLAhgMBr/ncNAA==", "dev": true }, "emoji-regex": { @@ -1332,9 +1332,9 @@ } }, "es-abstract": { - "version": "1.16.2", - "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.16.2.tgz", - "integrity": "sha512-jYo/J8XU2emLXl3OLwfwtuFfuF2w6DYPs+xy9ZfVyPkDcrauu6LYrw/q2TyCtrbc/KUdCiC5e9UajRhgNkVopA==", + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.16.3.tgz", + "integrity": "sha512-WtY7Fx5LiOnSYgF5eg/1T+GONaGmpvpPdCpSnYij+U2gDTL0UPfWrhDw7b2IYb+9NQJsYpCA0wOQvZfsd6YwRw==", "dev": true, "requires": { "es-to-primitive": "^1.2.1", @@ -2382,9 +2382,9 @@ "dev": true }, "node-releases": { - "version": "1.1.41", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-1.1.41.tgz", - "integrity": "sha512-+IctMa7wIs8Cfsa8iYzeaLTFwv5Y4r5jZud+4AnfymzeEXKBCavFX0KBgzVaPVqf0ywa6PrO8/b+bPqdwjGBSg==", + "version": "1.1.42", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-1.1.42.tgz", + "integrity": "sha512-OQ/ESmUqGawI2PRX+XIRao44qWYBBfN54ImQYdWVTQqUckuejOg76ysSqDBK8NG3zwySRVnX36JwDQ6x+9GxzA==", "dev": true, "requires": { "semver": "^6.3.0" @@ -3357,9 +3357,9 @@ "dev": true }, "union-replacer": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/union-replacer/-/union-replacer-1.1.0.tgz", - "integrity": "sha512-JvvR/TaAqLyb3DxDDZE3JDtcDtCVW7YMLuDx9Lieziw0t5c+ye3n1erPsioJH0oehbbdlAwxyeAP+Fs+O7rfsw==" + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/union-replacer/-/union-replacer-1.2.0.tgz", + "integrity": "sha512-8Mikag7fg8j6A65Wz1Ycph9gmjdUiv70+OjbiunPAzY+CuefHbIXrKeIdAm9DW+b4f2NUgPp2yehSvUu99znTw==" }, "uri-js": { "version": "4.2.2", diff --git a/package.json b/package.json index 3cecba2..9adce0c 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,7 @@ "url": "git+https://github.com/orchitech/gfm-escape.git" }, "dependencies": { - "union-replacer": "^1.1.0" + "union-replacer": "^1.2.0" }, "devDependencies": { "@babel/core": "^7.7.2", diff --git a/rollup.config.js b/rollup.config.js index 504ef9b..7d8de24 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -10,13 +10,26 @@ export default [ commonjs(), resolve(), babel({ - exclude: ['node_modules/**', 'tools/output/**', 'src/utils/**'], + exclude: ['node_modules/**', 'tools/output/**'], + presets: [ + [ + '@babel/env', { + modules: false, + targets: { + browsers: '> 1%, IE 11, not op_mini all, not dead', + node: 8, + esmodules: false, + }, + useBuiltIns: false, + }, + ], + ], }), ], output: [ { file: pkg.main, format: 'cjs' }, { file: pkg.module, format: 'es' }, - { file: pkg.browser, name: 'gfmescape', format: 'umd' }, + { file: pkg.browser, name: 'GfmEscape', format: 'umd' }, ], }, ]; diff --git a/src/GfmEscape.js b/src/GfmEscape.js index f95ef64..2676505 100644 --- a/src/GfmEscape.js +++ b/src/GfmEscape.js @@ -3,13 +3,14 @@ * that aims to be truly complete and survive back-translation. * @author Martin Cizek, Orchitech Solutions * @see https://github.com/orchitech/gfm-escape#readme - * @license + * @license MIT * @module */ import UnionReplacer from 'union-replacer'; import Syntax from './Syntax'; import defaultSetup from './defaultSetup'; +import applyProcessors from './utils/applyProcessors'; class GfmEscape { /** @@ -24,6 +25,8 @@ class GfmEscape { this.syntax = syntax; this.opts = opts ? { ...opts } : {}; this.replacer = new UnionReplacer('gm'); + this.preprocessors = []; + this.postprocessors = []; setup(syntax).forEach(([replace, enabled]) => { if (enabled) { replace.call(this); @@ -33,13 +36,15 @@ class GfmEscape { this.cache = {}; } - escape(str, gfmContext = {}, metadata = {}) { + escape(input, gfmContext = {}, metadata = {}) { const escapeCtx = { escape: this, gfmContext, metadata, }; - return this.replacer.replace(str, escapeCtx); + let str = applyProcessors.call(escapeCtx, input, this.preprocessors); + str = this.replacer.replace(str, escapeCtx); + return applyProcessors.call(escapeCtx, str, this.postprocessors); } } diff --git a/src/Syntax.js b/src/Syntax.js index 6d8f79a..b40497f 100644 --- a/src/Syntax.js +++ b/src/Syntax.js @@ -1,16 +1,18 @@ import BaseSyntax from './syntax/BaseSyntax'; import TextSyntax from './syntax/TextSyntax'; import LinkDestinationSyntax from './syntax/LinkDestinationSyntax'; +import LinkTitleSyntax from './syntax/LinkTitleSyntax'; import CmAutolinkSyntax from './syntax/CmAutolinkSyntax'; import CodeSpanSyntax from './syntax/CodeSpanSyntax'; const Syntax = BaseSyntax; /** - * Enumeration of GFM syntaxes used within {@link gfmSetupDefault}. + * GFM syntaxes used within {@link gfmSetupDefault}. */ Syntax.text = new TextSyntax(); Syntax.linkDestination = new LinkDestinationSyntax(); +Syntax.linkTitle = new LinkTitleSyntax(); Syntax.cmAutolink = new CmAutolinkSyntax(); Syntax.codeSpan = new CodeSpanSyntax(); diff --git a/src/defaultSetup.js b/src/defaultSetup.js index ccddbc9..9c9fc9c 100644 --- a/src/defaultSetup.js +++ b/src/defaultSetup.js @@ -5,13 +5,15 @@ import entityBackslashReplace from './replaces/entityBackslashReplace'; import entityEntityReplace from './replaces/entityEntityReplace'; import extAutolinkReplace from './replaces/extAutolinkReplace'; import inlineReplace from './replaces/inlineReplace'; -import linkDestinationSpecialsReplace from './replaces/linkDestinationSpecialsReplace'; +import linkDestinationReplace from './replaces/linkDestinationReplace'; import linkReplace from './replaces/linkReplace'; +import linkTitleReplace from './replaces/linkTitleReplace'; import strikethroughReplace from './replaces/strikethroughReplace'; import tableDelimiterRowReplace from './replaces/tableDelimiterRowReplace'; import tablePipeReplace from './replaces/tablePipeReplace'; import CodeSpanSyntax from './syntax/CodeSpanSyntax'; import LinkDestinationSyntax from './syntax/LinkDestinationSyntax'; +import LinkTitleSyntax from './syntax/LinkTitleSyntax'; const gfmSetupDefault = (s) => [ [codeSpanReplace, s.name === CodeSpanSyntax.name], @@ -20,7 +22,8 @@ const gfmSetupDefault = (s) => [ [tableDelimiterRowReplace, s.blocksInterpreted], [blockReplace, s.blocksInterpreted], [tablePipeReplace, true], - [linkDestinationSpecialsReplace, s.name === LinkDestinationSyntax.name], + [linkDestinationReplace, s.name === LinkDestinationSyntax.name], + [linkTitleReplace, s.name === LinkTitleSyntax.name], [linkReplace, s.isLink], [entityEntityReplace, s.isLink], [entityBackslashReplace, s.inlinesInterpreted], diff --git a/src/replaces/codeSpanReplace.js b/src/replaces/codeSpanReplace.js index 5809437..2ea70f0 100644 --- a/src/replaces/codeSpanReplace.js +++ b/src/replaces/codeSpanReplace.js @@ -1,21 +1,33 @@ -const BACKTICK_RUN_RE = /(^`*)|(`+(?!.))|`+/; +const longestBacktickString = (str) => { + const m = str.match(/`+/g); + return m + ? m.reduce((longest, current) => ( + current.length > longest.length ? current : longest + ), '') + : ''; +}; -function processBacktickRun({ match: [m, start, end] }) { - if (start !== undefined || m.length >= this.metadata.delimiter.length) { - this.metadata.delimiter = `${m}\``; - } - if (start !== undefined) { - this.metadata.space = start.length > 0 ? ' ' : ''; - } - if (end && !this.metadata.space) { - this.metadata.space = ' '; - } - return m; +const SHOULD_ADD_SPACE_RE = /^`|^[ \r\n].*?[^ \r\n].*[ \r\n]$|`$/; + +function scanDelimiters(input) { + const x = this.metadata; + x.extraBacktickString = longestBacktickString(input); + x.extraSpace = SHOULD_ADD_SPACE_RE.test(input) ? ' ' : ''; + return input; +} + +function addDelimiterExtras(output) { + const x = this.metadata; + const before = x.extraBacktickString + x.extraSpace; + const after = x.extraSpace + x.extraBacktickString; + return `${before}${output}${after}`; } /** - * Escape parentheses <, > and whitespace either as entites or in URL encoding. + * Adjust leading and trailing code span part according to contets and + * set metadata. */ export default function codeSpanReplace() { - this.replacer.addReplacement(BACKTICK_RUN_RE, processBacktickRun, true); + this.preprocessors.push(scanDelimiters); + this.postprocessors.unshift(addDelimiterExtras); } diff --git a/src/replaces/extAutolink/ExtWebAutolinkTransformers.js b/src/replaces/extAutolink/ExtWebAutolinkTransformers.js index f46be69..62024c3 100644 --- a/src/replaces/extAutolink/ExtWebAutolinkTransformers.js +++ b/src/replaces/extAutolink/ExtWebAutolinkTransformers.js @@ -45,7 +45,6 @@ class ExtWebAutolinkTransformers { /** * Escape characters in extended web autolink match according to the callers settings, * so that it is interpreted correctly in GFM. - * XXX needed / broken? * @param {String} str Link match portion to be escaped. * @private */ diff --git a/src/replaces/extAutolinkReplace.js b/src/replaces/extAutolinkReplace.js index 83ca93f..e5996c5 100644 --- a/src/replaces/extAutolinkReplace.js +++ b/src/replaces/extAutolinkReplace.js @@ -64,7 +64,7 @@ import autolinkedWwwReStr from '../utils/autolinkedWwwReStr'; import C from '../../tools/output/cmark-unicode'; import ExtWebAutolinkRenderer from './extAutolink/ExtWebAutolinkRenderer'; import escapePipesIfInTable from '../utils/escapePipesIfInTable'; -import applyPostprocessor from '../utils/applyPostprocessor'; +import wrapPostprocessor from '../utils/wrapPostprocessor'; const defaultOpts = Object.freeze({ breakUrl: false, @@ -72,8 +72,14 @@ const defaultOpts = Object.freeze({ breaker: '', allowedTransformations: ['entities', 'commonmark'], allowAddHttpScheme: false, + inImage: false, }); +// true if autolink match should be considered autolink in given matching context +const shouldProcess = ({ gfmContext, escape: { opts } }) => ( + !gfmContext.inLink && (!gfmContext.inImage || opts.autolink.inImage) +); + // $1: before, $2: linkMatch, $3: linkStart, $4: scheme, $5: www. const EXT_WEB_AUTOLINK_RE = (() => { // Standard domain character match @@ -96,19 +102,13 @@ const EXT_EMAIL_AUTOLINK_RE = /[-+.\w]+@(?:[-\w]+\.)+[-\w]*[^\W_](?![-@\w])/; /** * Process extended web autolink-like sequence in a plain text input. - * @param {String} m Whole match. - * @param {String} before h To-be-right-trimmed autolink sequence according to the spec. - * @param {String} linkStarCharacter preceding the autolink - underscore or empty. - * @param {String} linkMatch scheme or www. - * @param {String} scheme `http://`, `https://` or undefined. - * @param {String} www `www.` or undefined. - * @param {Object} escaper The escaper being executed. + * @param {MatchingContext} mctx matching context. * @private */ function processExtWebAutolink(mctx) { const [m, before, linkMatch, linkStart, scheme, www] = mctx.match; const outBefore = before ? `\\${before}` : ''; - if (this.gfmContext.inLink) { + if (!shouldProcess(this)) { mctx.jump(before.length + linkStart.length); return `${outBefore}${linkStart}`; } @@ -166,7 +166,7 @@ const GFM_EMAIL_UNDERSCORES_RE = /([a-z\d]_+)(?=[a-z\d])|_/gi; function processExtEmailAutolink(mctx) { const [emailMatch] = mctx.match; - if (this.gfmContext.inLink) { + if (!shouldProcess(this)) { const emailShred = emailMatch.match(/^.*?@/)[0]; mctx.jump(emailShred.length); return this.escape.escape(emailShred, this.gfmContext, this.metadata); @@ -185,7 +185,7 @@ export default function extAutolinkReplace() { return; } this.replacer.addReplacement(EXT_WEB_AUTOLINK_RE, - applyPostprocessor(processExtWebAutolink, escapePipesIfInTable), + wrapPostprocessor(processExtWebAutolink, escapePipesIfInTable), true); this.replacer.addReplacement(EXT_EMAIL_AUTOLINK_RE, processExtEmailAutolink, true); } diff --git a/src/replaces/linkDestinationReplace.js b/src/replaces/linkDestinationReplace.js new file mode 100644 index 0000000..58846a8 --- /dev/null +++ b/src/replaces/linkDestinationReplace.js @@ -0,0 +1,13 @@ +const LINK_DESTINATION_SPECIALS_RE = /[()<>]/; + +function renderEmptyLinkDestination(output) { + return output.length > 0 ? output : '<>'; +} + +/** + * Escape parentheses and brackets. + */ +export default function linkDestinationReplace() { + this.replacer.addReplacement(LINK_DESTINATION_SPECIALS_RE, '\\$&'); + this.postprocessors.unshift(renderEmptyLinkDestination); +} diff --git a/src/replaces/linkDestinationSpecialsReplace.js b/src/replaces/linkDestinationSpecialsReplace.js deleted file mode 100644 index 0b49858..0000000 --- a/src/replaces/linkDestinationSpecialsReplace.js +++ /dev/null @@ -1,8 +0,0 @@ -const LINK_DESTINATION_SPECIALS_RE = /[()<>]/; - -/** - * Escape parentheses. - */ -export default function linkDestinationSpecialsReplace() { - this.replacer.addReplacement(LINK_DESTINATION_SPECIALS_RE, '\\$&'); -} diff --git a/src/replaces/linkTitleReplace.js b/src/replaces/linkTitleReplace.js new file mode 100644 index 0000000..d79faba --- /dev/null +++ b/src/replaces/linkTitleReplace.js @@ -0,0 +1,85 @@ +import mergeOpts from '../utils/mergeOpts'; + +const DOUBLE_Q = '"'; +const SINGLE_Q = '\''; +const PAREN = '()'; + +const defaultOpts = Object.freeze({ + delimiters: [DOUBLE_Q, SINGLE_Q, PAREN], + alwaysEscapeDelimiters: [], +}); + +const LINK_TITLE_DELIMS_RE = /['"()]/g; + +const charDelims = { + '"': DOUBLE_Q, + '\'': SINGLE_Q, + '(': PAREN, + ')': PAREN, +}; + +const bestDelimiter = (str, opts) => { + if (!Array.isArray(opts.delimiters)) { + return opts.delimiters; + } + if (opts.delimiters.length === 1) { + return opts.delimiters[0]; + } + const m = str.match(LINK_TITLE_DELIMS_RE); + if (!m) { + return opts.delimiters[0]; + } + const e = {}; + e[DOUBLE_Q] = 0; + e[SINGLE_Q] = 0; + e[PAREN] = 0; + m.forEach((c) => { + e[charDelims[c]] += 1; + }); + opts.alwaysEscapeDelimiters.forEach((d) => { + e[DOUBLE_Q] += d === DOUBLE_Q ? 0 : e[d]; + e[SINGLE_Q] += d === SINGLE_Q ? 0 : e[d]; + e[PAREN] += d === PAREN ? 0 : e[d]; + }); + return opts.delimiters.reduce((best, d) => ( + e[d] < e[best] ? d : best + )); +}; + +function scanDelimiters(input) { + const x = this.metadata; + const opts = this.escape.opts.linkTitle; + x.delimiter = bestDelimiter(input, opts); + if (x.delimiter === PAREN) { + x.startDelimiter = '('; + x.endDelimiter = ')'; + } else { + x.startDelimiter = x.delimiter; + x.endDelimiter = x.delimiter; + } + const escaped = {}; + escaped[x.delimiter] = true; + opts.alwaysEscapeDelimiters.forEach((d) => { + escaped[d] = true; + }); + this.linkTitleEscapedDelimiters = escaped; + return input; +} + +function processLinkTitleDelim({ match: [c] }) { + if (this.linkTitleEscapedDelimiters[charDelims[c]]) { + return `\\${c}`; + } + return c; +} + +/** + * Escape parentheses. + */ +export default function linkTitleReplace() { + if (!mergeOpts(this.opts, 'linkTitle', defaultOpts, true)) { + return; + } + this.preprocessors.push(scanDelimiters); + this.replacer.addReplacement(LINK_TITLE_DELIMS_RE, processLinkTitleDelim, true); +} diff --git a/src/syntax/CodeSpanSyntax.js b/src/syntax/CodeSpanSyntax.js index 3b0780a..2734c65 100644 --- a/src/syntax/CodeSpanSyntax.js +++ b/src/syntax/CodeSpanSyntax.js @@ -12,6 +12,10 @@ class CodeSpanSyntax extends BaseSyntax { static get name() { return NAME; } + + isEncodable(str) { + return str.length > 0; + } } export default CodeSpanSyntax; diff --git a/src/syntax/LinkTitleSyntax.js b/src/syntax/LinkTitleSyntax.js new file mode 100644 index 0000000..2b468a5 --- /dev/null +++ b/src/syntax/LinkTitleSyntax.js @@ -0,0 +1,17 @@ +import BaseSyntax from './BaseSyntax'; + +const NAME = 'linkTitle'; + +class LinkTitleSyntax extends BaseSyntax { + constructor() { + super(NAME); + this.isLink = true; + this.inlinesInterpreted = false; + } + + static get name() { + return NAME; + } +} + +export default LinkTitleSyntax; diff --git a/src/utils/applyProcessors.js b/src/utils/applyProcessors.js new file mode 100644 index 0000000..31eb090 --- /dev/null +++ b/src/utils/applyProcessors.js @@ -0,0 +1,3 @@ +export default function applyProcessors(input, processors) { + return processors.reduce((str, proc) => (proc.call(this, str)), input); +} diff --git a/src/utils/applyPostprocessor.js b/src/utils/wrapPostprocessor.js similarity index 69% rename from src/utils/applyPostprocessor.js rename to src/utils/wrapPostprocessor.js index 142c88b..53a1d56 100644 --- a/src/utils/applyPostprocessor.js +++ b/src/utils/wrapPostprocessor.js @@ -1,4 +1,4 @@ -export default function applyPostprocessor(processFn, postProcessFn) { +export default function wrapPostprocessor(processFn, postProcessFn) { return function wrapper() { // eslint-disable-next-line prefer-rest-params return postProcessFn.call(this, processFn.apply(this, arguments)); diff --git a/test/behavior/itBacktranslates.js b/test/behavior/itBacktranslates.js index 003cb7b..90aaa76 100644 --- a/test/behavior/itBacktranslates.js +++ b/test/behavior/itBacktranslates.js @@ -7,6 +7,7 @@ const SOME_TEXT = 'GfmEscape'; const syntaxCreators = { text: (md) => md, linkText: (md) => `[${md}](${SOME_URL})`, + imageText: (md) => `![${md}](${SOME_URL})`, linkDestination: (md) => `[${SOME_TEXT}](${md})`, cmAutolink: (md) => `<${md}>`, }; @@ -25,6 +26,10 @@ const syntaxMatchers = { expect(render).toHaveSameTextContentAs(input); expect(render).toHaveLinks([SOME_URL]); }, + imageText: (render) => { + expect(render).toHaveSingleChildNode(); + // TODO: add checks related to image + }, linkDestination: (render, input) => { expect(render).toHaveSingleChildNode(); expect(render).toHaveSameTextContentAs(SOME_TEXT); @@ -51,6 +56,9 @@ const contextualSyntax = (syntaxName, gfmContext) => { if (gfmContext.inLink) { return 'linkText'; } + if (gfmContext.inImage) { + return 'imageText'; + } } return syntaxName; }; diff --git a/test/code-span.spec.js b/test/code-span.spec.js index 0e4330e..785a67f 100644 --- a/test/code-span.spec.js +++ b/test/code-span.spec.js @@ -9,31 +9,41 @@ describe('code span syntax', () => { it('returns one backtick if there is no backtick', () => { const metadata = {}; expect(mdEscaper.escape('some thing', {}, metadata)).toBe('some thing'); - expect(metadata.delimiter).toBe('`'); - expect(metadata.space).toBe(''); + expect(metadata.extraBacktickString).toBe(''); + expect(metadata.extraSpace).toBe(''); }); - it('returns one more backtick than the longest backtick run in text', () => { const metadata = {}; - expect(mdEscaper.escape('``some `thing', {}, metadata)).toBe('``some `thing'); - expect(metadata.delimiter).toBe('```'); - expect(metadata.space).toBe(' '); - expect(mdEscaper.escape('some `thing``', {}, metadata)).toBe('some `thing``'); - expect(metadata.delimiter).toBe('```'); - expect(metadata.space).toBe(' '); + expect(mdEscaper.escape('``some `thing', {}, metadata)).toBe('`` ``some `thing ``'); + expect(metadata.extraBacktickString).toBe('``'); + expect(metadata.extraSpace).toBe(' '); + expect(mdEscaper.escape('some `thing``', {}, metadata)).toBe('`` some `thing`` ``'); + expect(metadata.extraBacktickString).toBe('``'); + expect(metadata.extraSpace).toBe(' '); }); - - it('does not escape anything outside table', () => { + it('adds spaces if there is space at start and end', () => { + expect(mdEscaper.escape(' x ')).toBe(' x '); + }); + it('does not add space if there is space only at start', () => { + expect(mdEscaper.escape('\nx')).toBe('\nx'); + }); + it('does not add space if there is space only at end', () => { + expect(mdEscaper.escape('x\r\n')).toBe('x\r\n'); + }); + it('does not add space when there are only spaces', () => { + const str = '\n \r\n \r '; + expect(mdEscaper.escape(str).length).toBe(str.length); + }); + it('does not escape pipes outside table', () => { const metadata = {}; - expect(mdEscaper.escape('*_x_*|`~~a~~', {}, metadata)).toBe('*_x_*|`~~a~~'); - expect(metadata.delimiter).toBe('``'); - expect(metadata.space).toBe(''); + expect(mdEscaper.escape('*_x_*|`~~a~~', {}, metadata)).toBe('`*_x_*|`~~a~~`'); }); - it('escapes pipes in table', () => { const metadata = {}; expect(mdEscaper.escape('*_x_*|~~a~~', { inTable: true }, metadata)).toBe('*_x_*\\|~~a~~'); - expect(metadata.delimiter).toBe('`'); - expect(metadata.space).toBe(''); + }); + it('escapes pipe in table when at start', () => { + const metadata = {}; + expect(mdEscaper.escape('|', { inTable: true }, metadata)).toBe('\\|'); }); }); diff --git a/test/link-destination.spec.js b/test/link-destination.spec.js index d49ee17..5c8bd9b 100644 --- a/test/link-destination.spec.js +++ b/test/link-destination.spec.js @@ -9,4 +9,5 @@ describeEscapeBehavior('link destination syntax', mdEscaper, [ 'http://orchi.tech/_noemphasis_ here', ['_foo&bar_', '_foo&amp;bar_'], ['', '\\'], + ['', '<>'], ]); diff --git a/test/link-title.spec.js b/test/link-title.spec.js new file mode 100644 index 0000000..e74c842 --- /dev/null +++ b/test/link-title.spec.js @@ -0,0 +1,42 @@ +const GfmEscape = require('../dist/gfm-escape.cjs'); + +const mdEscaper = new GfmEscape({}, GfmEscape.Syntax.linkTitle); + +describe('link title syntax', () => { + it('picks default delimiter', () => { + const metadata = {}; + expect(mdEscaper.escape('foo', {}, metadata)).toBe('foo'); + expect(metadata.delimiter).toBe('"'); + expect(metadata.startDelimiter).toBe('"'); + expect(metadata.endDelimiter).toBe('"'); + }); + it('picks unused second delimiter if first is used', () => { + const metadata = {}; + expect(mdEscaper.escape('foo"bar', {}, metadata)).toBe('foo"bar'); + expect(metadata.delimiter).toBe('\''); + expect(metadata.startDelimiter).toBe('\''); + expect(metadata.endDelimiter).toBe('\''); + }); + it('picks unused third delimiter if first and second are used', () => { + const metadata = {}; + expect(mdEscaper.escape('foo\'"\'bar', {}, metadata)).toBe('foo\'"\'bar'); + expect(metadata.delimiter).toBe('()'); + expect(metadata.startDelimiter).toBe('('); + expect(metadata.endDelimiter).toBe(')'); + }); + it('picks the firt delimiter when all are equally used', () => { + const metadata = {}; + expect(mdEscaper.escape('("\'foo\'")', {}, metadata)).toBe('(\\"\'foo\'\\")'); + expect(metadata.delimiter).toBe('"'); + }); + it('favorites delimiter with forced escaping', () => { + const metadata = {}; + const escaper = new GfmEscape({ + linkTitle: { + alwaysEscapeDelimiters: ['()'], + }, + }, GfmEscape.Syntax.linkTitle); + expect(escaper.escape('("\'foo\'")', {}, metadata)).toBe('\\("\'foo\'"\\)'); + expect(metadata.delimiter).toBe('()'); + }); +});