improve commonmark compliance

This commit is contained in:
Estevão Soares dos Santos 2022-04-28 08:38:48 +01:00
parent 7d2ede8dd9
commit fb31f631e9
17 changed files with 130 additions and 47 deletions

View File

@ -2,6 +2,7 @@
* Created by Tivie on 12-11-2014. * Created by Tivie on 12-11-2014.
*/ */
const commonmark = require('commonmark-spec');
module.exports = function (grunt) { module.exports = function (grunt) {
if (grunt.option('q') || grunt.option('quiet')) { if (grunt.option('q') || grunt.option('quiet')) {
@ -230,6 +231,13 @@ module.exports = function (grunt) {
grunt.task.run(['lint', 'concat:test', 'mochaTest:single', 'clean']); grunt.task.run(['lint', 'concat:test', 'mochaTest:single', 'clean']);
}); });
grunt.registerTask('extract-commonmark-tests', function () {
'use strict';
let commonmark = require('commonmark-spec');
let testsuite = JSON.stringify(commonmark.tests, null, 2);
grunt.file.write('test/functional/makehtml/cases/commonmark.testsuite.json', testsuite)
});
/** /**
* Tasks * Tasks
*/ */
@ -237,19 +245,14 @@ module.exports = function (grunt) {
grunt.registerTask('test-functional', ['concat:test', 'mochaTest:functional', 'clean']); grunt.registerTask('test-functional', ['concat:test', 'mochaTest:functional', 'clean']);
grunt.registerTask('test-unit', ['concat:test', 'mochaTest:unit', 'clean']); grunt.registerTask('test-unit', ['concat:test', 'mochaTest:unit', 'clean']);
grunt.registerTask('test-cli', ['clean', 'lint', 'concat:test', 'mochaTest:cli', 'clean']); grunt.registerTask('test-cli', ['clean', 'lint', 'concat:test', 'mochaTest:cli', 'clean']);
grunt.registerTask('test-commonmark', ['clean', 'lint', 'concat:test', 'mochaTest:commonmark', 'clean']); grunt.registerTask('test-commonmark', ['clean', 'lint', 'concat:test', 'extract-commonmark-tests', 'mochaTest:commonmark', 'clean']);
grunt.registerTask('performance', ['concat:test', 'performancejs', 'clean']); grunt.registerTask('performance', ['concat:test', 'performancejs', 'clean']);
grunt.registerTask('build', ['test', 'concat:dist', 'concat:cli', 'uglify:dist', 'uglify:cli', 'endline']); grunt.registerTask('build', ['test', 'concat:dist', 'concat:cli', 'uglify:dist', 'uglify:cli', 'endline']);
grunt.registerTask('build-without-test', ['concat:dist', 'uglify', 'endline']); grunt.registerTask('build-without-test', ['concat:dist', 'uglify', 'endline']);
grunt.registerTask('prep-release', ['build', 'performance', 'generate-changelog']); grunt.registerTask('prep-release', ['build', 'performance', 'generate-changelog']);
grunt.registerTask('extract-commonmark-tests', function () {
'use strict';
let commonmark = require('commonmark-spec');
grunt.file.write('test/functional/makehtml/cases/commonmark.testsuite.json', JSON.stringify(commonmark.tests, null, 2))
});
// Default task(s). // Default task(s).
grunt.registerTask('default', ['test']); grunt.registerTask('default', ['test']);

35
docs/spec-compliance.md Normal file
View File

@ -0,0 +1,35 @@
# Spec Compliance
## Commonmark
Compliance percentage:
### How to enable commonmark flavor
Enable Commonmark flavor
```
let converter = new showdown.Converter();
converter.setFlavor('commonmark');
```
### Known differences:
#### ATX Headings
- Showdown doesn't support empty headings
Input:
```md
#
```
Showdown Output:
```html
<p>#</p>
```
Commonmark output:
```
<h1></h1>
```

13
package-lock.json generated
View File

@ -29,6 +29,7 @@
"grunt-endline": "^0.7.0", "grunt-endline": "^0.7.0",
"grunt-eslint": "^24.0.0", "grunt-eslint": "^24.0.0",
"grunt-mocha-test": "^0.13.3", "grunt-mocha-test": "^0.13.3",
"html-prettify": "^1.0.6",
"karma": "^6.3.17", "karma": "^6.3.17",
"karma-browserstack-launcher": "^1.6.0", "karma-browserstack-launcher": "^1.6.0",
"karma-chai": "^0.1.0", "karma-chai": "^0.1.0",
@ -3271,6 +3272,12 @@
"node": ">=12" "node": ">=12"
} }
}, },
"node_modules/html-prettify": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/html-prettify/-/html-prettify-1.0.6.tgz",
"integrity": "sha512-a2e5NX3pjP1io0Up0d3JOr+tMwKy8IsT4JaMMLznXzuuqPlthnvNdKd3lesQpu37/XWiA28FvDYQU0w/RlAymA==",
"dev": true
},
"node_modules/htmlparser2": { "node_modules/htmlparser2": {
"version": "3.8.3", "version": "3.8.3",
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-3.8.3.tgz", "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-3.8.3.tgz",
@ -9249,6 +9256,12 @@
"whatwg-encoding": "^2.0.0" "whatwg-encoding": "^2.0.0"
} }
}, },
"html-prettify": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/html-prettify/-/html-prettify-1.0.6.tgz",
"integrity": "sha512-a2e5NX3pjP1io0Up0d3JOr+tMwKy8IsT4JaMMLznXzuuqPlthnvNdKd3lesQpu37/XWiA28FvDYQU0w/RlAymA==",
"dev": true
},
"htmlparser2": { "htmlparser2": {
"version": "3.8.3", "version": "3.8.3",
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-3.8.3.tgz", "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-3.8.3.tgz",

View File

@ -60,6 +60,7 @@
"grunt-endline": "^0.7.0", "grunt-endline": "^0.7.0",
"grunt-eslint": "^24.0.0", "grunt-eslint": "^24.0.0",
"grunt-mocha-test": "^0.13.3", "grunt-mocha-test": "^0.13.3",
"html-prettify": "^1.0.6",
"karma": "^6.3.17", "karma": "^6.3.17",
"karma-browserstack-launcher": "^1.6.0", "karma-browserstack-launcher": "^1.6.0",
"karma-chai": "^0.1.0", "karma-chai": "^0.1.0",

View File

@ -303,7 +303,8 @@ showdown.Converter = function (converterOptions) {
text = '\n\n' + text + '\n\n'; text = '\n\n' + text + '\n\n';
// detab // detab
text = showdown.subParser('makehtml.detab')(text, options, globals); //text = showdown.subParser('makehtml.detab')(text, options, globals);
text = showdown.helper.normalizeLeadingTabs(text);
/** /**
* Strip any lines consisting only of spaces and tabs. * Strip any lines consisting only of spaces and tabs.

View File

@ -705,6 +705,14 @@ showdown.helper._populateAttributes = function (attributes) {
return text; return text;
}; };
showdown.helper.normalizeLeadingTabs = function (text) {
// 1. (1 to 3 spaces followed by a tab at the start of the line) becomes (1 tab)
text = text.replace(/^ {1,3}\t/gm, '\t');
// 2.
return text;
};
/** /**
* Remove one level of line-leading tabs or spaces * Remove one level of line-leading tabs or spaces
* @param {string} text * @param {string} text

View File

@ -30,21 +30,9 @@ var showdown = {},
noHeaderId: true, noHeaderId: true,
ghCodeBlocks: false ghCodeBlocks: false
}, },
ghost: { commonmark: {
omitExtraWLInCodeBlocks: true, noHeaderId: true,
parseImgDimensions: true, requireSpaceBeforeHeadingText: true
simplifiedAutoLink: true,
literalMidWordUnderscores: true,
strikethrough: true,
tables: true,
tablesHeaderId: true,
ghCodeBlocks: true,
tasklists: true,
smoothLivePreview: true,
simpleLineBreaks: true,
requireSpaceBeforeHeadingText: true,
ghMentions: false,
encodeEmails: true
}, },
vanilla: getDefaultOpts(true), vanilla: getDefaultOpts(true),
allOn: allOptionsOn() allOn: allOptionsOn()

View File

@ -23,7 +23,7 @@ showdown.subParser('makehtml.codeBlock', function (text, options, globals) {
// sentinel workarounds for lack of \A and \Z, safari\khtml bug // sentinel workarounds for lack of \A and \Z, safari\khtml bug
text += '¨0'; text += '¨0';
let pattern = /(?:\n\n|^)((?:(?:[ ]{4}|\t).*\n+)+)(\n*[ ]{0,3}[^ \t\n]|(?=¨0))/g; let pattern = /(?:\n\n|^)((?:(?: {4}|\t).*\n+)+)(\n* {0,3}[^ \t\n]|(?=¨0))/g;
text = text.replace(pattern, function (wholeMatch, m1, m2) { text = text.replace(pattern, function (wholeMatch, m1, m2) {
let codeblock = m1, let codeblock = m1,
nextChar = m2, nextChar = m2,
@ -55,7 +55,7 @@ showdown.subParser('makehtml.codeBlock', function (text, options, globals) {
codeblock = captureStartEvent.matches.codeblock; codeblock = captureStartEvent.matches.codeblock;
codeblock = showdown.helper.outdent(codeblock); codeblock = showdown.helper.outdent(codeblock);
codeblock = showdown.subParser('makehtml.encodeCode')(codeblock, options, globals); codeblock = showdown.subParser('makehtml.encodeCode')(codeblock, options, globals);
codeblock = showdown.subParser('makehtml.detab')(codeblock, options, globals); //codeblock = showdown.subParser('makehtml.detab')(codeblock, options, globals);
codeblock = codeblock.replace(/^\n+/g, ''); // trim leading newlines codeblock = codeblock.replace(/^\n+/g, ''); // trim leading newlines
codeblock = codeblock.replace(/\n+$/g, ''); // trim trailing newlines codeblock = codeblock.replace(/\n+$/g, ''); // trim trailing newlines
attributes = captureStartEvent.attributes; attributes = captureStartEvent.attributes;

View File

@ -12,6 +12,7 @@
showdown.subParser('makehtml.detab', function (text, options, globals) { showdown.subParser('makehtml.detab', function (text, options, globals) {
'use strict'; 'use strict';
return text;
let startEvent = new showdown.Event('makehtml.detab.onStart', text); let startEvent = new showdown.Event('makehtml.detab.onStart', text);
startEvent startEvent
.setOutput(text) .setOutput(text)

View File

@ -68,7 +68,7 @@ showdown.subParser('makehtml.githubCodeBlock', function (text, options, globals)
let lang = infostring.trim().split(' ')[0]; let lang = infostring.trim().split(' ')[0];
codeblock = captureStartEvent.matches.codeblock; codeblock = captureStartEvent.matches.codeblock;
codeblock = showdown.subParser('makehtml.encodeCode')(codeblock, options, globals); codeblock = showdown.subParser('makehtml.encodeCode')(codeblock, options, globals);
codeblock = showdown.subParser('makehtml.detab')(codeblock, options, globals); //codeblock = showdown.subParser('makehtml.detab')(codeblock, options, globals);
codeblock = codeblock codeblock = codeblock
.replace(/^\n+/g, '') // trim leading newlines .replace(/^\n+/g, '') // trim leading newlines
.replace(/\n+$/g, ''); // trim trailing whitespace .replace(/\n+$/g, ''); // trim trailing whitespace

View File

@ -73,16 +73,18 @@ showdown.subParser('makehtml.heading', function (text, options, globals) {
startEvent = globals.converter.dispatch(startEvent); startEvent = globals.converter.dispatch(startEvent);
text = startEvent.output; text = startEvent.output;
let setextRegexH1 = (options.smoothLivePreview) ? /^(.+)[ \t]*\n={2,}[ \t]*\n+/gm : /^(.+)[ \t]*\n=+[ \t]*\n+/gm, let setextRegexH1 = (options.smoothLivePreview) ? /^(.+[ \t]*\n)(.+[ \t]*\n)?(.+[ \t]*\n)?={2,}[ \t]*\n+/gm : /^( {0,3}[^ \t\n].+[ \t]*\n)(.+[ \t]*\n)?(.+[ \t]*\n)? {0,3}=+[ \t]*$/gm,
setextRegexH2 = (options.smoothLivePreview) ? /^(.+)[ \t]*\n-{2,}[ \t]*\n+/gm : /^(.+)[ \t]*\n-+[ \t]*\n+/gm, setextRegexH2 = (options.smoothLivePreview) ? /^(.+[ \t]*\n)(.+[ \t]*\n)?(.+[ \t]*\n)?-{2,}[ \t]*\n+/gm : /^( {0,3}[^ \t\n].+[ \t]*\n)(.+[ \t]*\n)?(.+[ \t]*\n)? {0,3}-+[ \t]*$/gm,
atxRegex = (options.requireSpaceBeforeHeadingText) ? /^(#{1,6})[ \t]+(.+?)[ \t]*#*\n+/gm : /^(#{1,6})[ \t]*(.+?)[ \t]*#*\n+/gm; atxRegex = (options.requireSpaceBeforeHeadingText) ? /^ {0,3}(#{1,6})[ \t]+(.+?)(?:[ \t]+#+)?[ \t]*$/gm : /^ {0,3}(#{1,6})[ \t]*(.+?)[ \t]*#*[ \t]*$/gm;
text = text.replace(setextRegexH1, function (wholeMatch, headingText) { text = text.replace(setextRegexH1, function (wholeMatch, line1, line2, line3) {
let headingText = line1.trim() + ((line2) ? '\n' + line2.trim() : '') + ((line3) ? '\n' + line3.trim() : '');
let id = (options.noHeaderId) ? null : showdown.subParser('makehtml.heading.id')(headingText, options, globals); let id = (options.noHeaderId) ? null : showdown.subParser('makehtml.heading.id')(headingText, options, globals);
return parseHeader(setextRegexH1, wholeMatch, headingText, options.headerLevelStart, id); return parseHeader(setextRegexH1, wholeMatch, headingText, options.headerLevelStart, id);
}); });
text = text.replace(setextRegexH2, function (wholeMatch, headingText) { text = text.replace(setextRegexH2, function (wholeMatch, line1, line2, line3) {
let headingText = line1.trim() + ((line2) ? '\n' + line2.trim() : '') + ((line3) ? '\n' + line3.trim() : '');
let id = (options.noHeaderId) ? null : showdown.subParser('makehtml.heading.id')(headingText, options, globals); let id = (options.noHeaderId) ? null : showdown.subParser('makehtml.heading.id')(headingText, options, globals);
return parseHeader(setextRegexH2, wholeMatch, headingText, options.headerLevelStart + 1, id); return parseHeader(setextRegexH2, wholeMatch, headingText, options.headerLevelStart + 1, id);
}); });
@ -154,7 +156,7 @@ showdown.subParser('makehtml.heading.id', function (m, options, globals) {
.toLowerCase(); .toLowerCase();
} else { } else {
title = title title = title
.replace(/[^\w]/g, '') .replace(/\W/g, '')
.toLowerCase(); .toLowerCase();
} }

View File

@ -15,7 +15,7 @@ showdown.subParser('makehtml.paragraphs', function (text, options, globals) {
for (var i = 0; i < end; i++) { for (var i = 0; i < end; i++) {
var str = grafs[i]; var str = grafs[i];
// if this is an HTML marker, copy it // if this is an HTML marker, copy it
if (str.search(/¨(K|G)(\d+)\1/g) >= 0) { if (str.search(/¨([KG])(\d+)\1/g) >= 0) {
grafsOut.push(str); grafsOut.push(str);
// test for presence of characters to prevent empty lines being parsed // test for presence of characters to prevent empty lines being parsed

View File

@ -1 +1 @@
<p># This is an H1</p> <h1>This is an H1</h1>

View File

@ -3,19 +3,35 @@
*/ */
// jshint ignore: start // jshint ignore: start
var bootstrap = require('./makehtml.bootstrap.js'), let bootstrap = require('./makehtml.bootstrap.js'),
converter = new bootstrap.showdown.Converter(), converter = new bootstrap.showdown.Converter({
noHeaderId: true,
requireSpaceBeforeHeadingText: true
}),
assertion = bootstrap.assertion, assertion = bootstrap.assertion,
testsuite = bootstrap.getJsonTestSuite('test/functional/makehtml/cases/commonmark.testsuite.json'); testsuite = bootstrap.getJsonTestSuite('test/functional/makehtml/cases/commonmark.testsuite.json');
describe('makeHtml() commonmark testsuite', function () { describe('makeHtml() commonmark testsuite', function () {
'use strict'; 'use strict';
for (var section in testsuite) { for (let section in testsuite) {
if (testsuite.hasOwnProperty(section)) { if (testsuite.hasOwnProperty(section)) {
describe(section, function () { describe(section, function () {
for (var i = 0; i < testsuite[section].length; ++i) { for (let i = 0; i < testsuite[section].length; ++i) {
it(testsuite[section][i].name, assertion(testsuite[section][i], converter)); let name = testsuite[section][i].name;
switch (name) {
case 'ATX headings_79': // empty headings don't make sense
case 'Setext headings_92': // lazy continuation is needed for compatibility
case 'Setext headings_93': // lazy continuation is needed for compatibility
case 'Setext headings_94': // lazy continuation is needed for compatibility
continue;
}
if (testsuite[section][i].name === 'ATX headings_79') {
continue;
}
it(name, assertion(testsuite[section][i], converter, true));
} }
}); });
} }

View File

@ -8,6 +8,7 @@
require('source-map-support').install(); require('source-map-support').install();
require('chai').should(); require('chai').should();
const htmlPrettify = require('html-prettify');
let fs = require('fs'); let fs = require('fs');
function getTestSuite (dir) { function getTestSuite (dir) {
@ -52,7 +53,13 @@
let section = jsonArray[i].section; let section = jsonArray[i].section;
let name = jsonArray[i].section + '_' + (jsonArray[i].example || jsonArray[i].number); let name = jsonArray[i].section + '_' + (jsonArray[i].example || jsonArray[i].number);
let md = jsonArray[i].markdown; let md = jsonArray[i].markdown;
// transformations
md = md.replace(/→/g, '\t'); // replace → with tabs
let html = jsonArray[i].html; let html = jsonArray[i].html;
// transformations
html = html.replace(/→/g, '\t'); // replace → with tabs
if (!tcObj.hasOwnProperty(section)) { if (!tcObj.hasOwnProperty(section)) {
tcObj[section] = []; tcObj[section] = [];
} }
@ -67,10 +74,15 @@
} }
function assertion (testCase, converter) { function assertion (testCase, converter, prettify) {
prettify = prettify || false;
return function () { return function () {
testCase.actual = converter.makeHtml(testCase.input); testCase.actual = converter.makeHtml(testCase.input);
testCase = normalize(testCase); // transformations for readability
//testCase.expected = testCase.expected.replace(/\t/g, '→');
//testCase.actual = testCase.actual.replace(/\t/g, '→');
testCase = normalize(testCase, prettify);
// Compare // Compare
testCase.actual.should.equal(testCase.expected, testCase.file); testCase.actual.should.equal(testCase.expected, testCase.file);
@ -78,7 +90,7 @@
} }
//Normalize input/output //Normalize input/output
function normalize (testCase) { function normalize (testCase, prettify) {
// Normalize line returns // Normalize line returns
testCase.expected = testCase.expected.replace(/(\r\n)|\n|\r/g, '\n'); testCase.expected = testCase.expected.replace(/(\r\n)|\n|\r/g, '\n');
@ -96,9 +108,12 @@
testCase.expected = testCase.expected.trim(); testCase.expected = testCase.expected.trim();
testCase.actual = testCase.actual.trim(); testCase.actual = testCase.actual.trim();
//Beautify //prettify
//testCase.expected = beautify(testCase.expected, beauOptions); if (prettify) {
//testCase.actual = beautify(testCase.actual, beauOptions); testCase.expected = htmlPrettify(testCase.expected);
testCase.actual = htmlPrettify(testCase.actual);
}
// Normalize line returns // Normalize line returns
testCase.expected = testCase.expected.replace(/(\r\n)|\n|\r/g, '\n'); testCase.expected = testCase.expected.replace(/(\r\n)|\n|\r/g, '\n');