diff --git a/README.textile b/README.textile
index 7f85852..5af318a 100644
--- a/README.textile
+++ b/README.textile
@@ -10,12 +10,29 @@ h2. Install
bc. $ npm install textile-js
-h2. Options
+h2. The @textile@ function
The basic interface mimics "marked":https://github.com/chjj/marked, the popular markdown parser. So if you use that in your project then you can support Textile as well with minimal effort.
-Currently, the only supported option is @breaks@ which can be used to enable/disable the default behavior of line-breaking single newlines within blocks.
+Currently, the only option imported from marked is @breaks@ which can be used to enable/disable the default behavior of line-breaking single newlines within blocks.
+h3. Syntax
+
+bc. textile( textileString [, options] )
+
+|*Argument*|*Type*|*Notes*|
+|textileString|@string@|String of textile source to be compiled.|
+|"options":#options|@object@|Hash of options. Can also use @textile.setOptions@.|
+
+h3. Options
+
+|*Member*|*Type*|*Default*|*Notes*|
+|break|@boolean@|true|Convert single-line linebreaks to @
@.|
+|cssClassOriginalLineNumber|@string@|''|Add this CSS class name, to each elements of HTML output, mapped to a the line numbers of input text.
+Works only when @showOriginalLineNumber@ option is enabled.|
+|lineOffset|@integer@|0|Line number offset of the first char in input text, for @showOriginalLineNumber@ option.|
+|showOriginalLineNumber|@boolean@|false|Maps the elements of HTML output, with the line numbers of input text.
+An HTML attribute @data-line@ is added to the HTML block elements. It's value is the line number in input text, of the first char of text inside that HTML block.|
h2. Usage
diff --git a/bin/textile b/bin/textile
index eccd0e3..bee7231 100755
--- a/bin/textile
+++ b/bin/textile
@@ -27,6 +27,7 @@ process.argv.slice( 2 ).forEach( function ( m, i, s ) {
// parse arguments
var options = {};
+var lib_options = {};
while ( args.length ) {
var arg = args.shift();
if ( arg === '-t' || arg === '--tokens' ) {
@@ -38,6 +39,11 @@ while ( args.length ) {
else if ( arg === '-o' || arg === '--output' ) {
options.output = args.shift();
}
+ else if ( arg === '--show-original-line-number' ) {
+ lib_options.showOriginalLineNumber = true;
+ } else if ( arg === '--show-original-line-number-line-offset' ) {
+ lib_options.lineOffset = parseInt( args.shift(), 10);
+ }
else {
if ( !options.files ) { options.files = []; }
options.files.shift( arg );
@@ -52,8 +58,8 @@ if ( options.input ) {
// writer
var data = '';
function writeData () {
- data = options.tokens ? JSON.stringify( textile.jsonml( data ), null, 2 )
- : textile( data );
+ data = options.tokens ? JSON.stringify( textile.jsonml( data, lib_options ), null, 2 )
+ : textile( data, lib_options );
if ( options.output ) {
fs.writeFileSync( options.output, data );
}
diff --git a/src/index.js b/src/index.js
index 64ae6cf..60c3e71 100644
--- a/src/index.js
+++ b/src/index.js
@@ -14,14 +14,20 @@ function textile ( txt, opt ) {
// get a throw-away copy of options
opt = merge( merge({}, textile.defaults ), opt || {});
// run the converter
- return parseFlow( txt, opt ).map( toHTML ).join( '' );
+ return parseFlow( txt, opt, opt.lineOffset ).map( toHTML ).join( '' );
};
module.exports = textile;
// options
textile.defaults = {
// single-line linebreaks are converted to
by default
- 'breaks': true
+ 'breaks': true,
+ // by default, don't map the elements of HTML output, with the line numbers of input text
+ 'showOriginalLineNumber': false,
+ // line number offset of the first char of input text, for showOriginalLineNumber option
+ 'lineOffset': 0,
+ // by default, don't set a special CSS class name to each HTML element mapped to an original line number
+ 'cssClassOriginalLineNumber': ''
};
textile.setOptions = textile.setoptions = function ( opt ) {
merge( textile.defaults, opt );
@@ -35,6 +41,6 @@ textile.jsonml = function ( txt, opt ) {
// get a throw-away copy of options
opt = merge( merge({}, textile.defaults ), opt || {});
// parse and return tree
- return [ 'html' ].concat( parseFlow( txt, opt ) );
+ return [ 'html' ].concat( parseFlow( txt, opt, opt.lineOffset ) );
};
textile.serialize = toHTML;
diff --git a/src/ribbon.js b/src/ribbon.js
index f4eb0f6..5b65b12 100644
--- a/src/ribbon.js
+++ b/src/ribbon.js
@@ -13,6 +13,14 @@ module.exports = function ribbon ( feed ) {
return self;
},
+ getSlot: () => {
+ return slot || 0;
+ },
+
+ getPos: () => {
+ return pos || 0;
+ },
+
load: () => {
pos = slot;
feed = org.slice( pos );
diff --git a/src/textile/attr.js b/src/textile/attr.js
index 294f6aa..67816ce 100644
--- a/src/textile/attr.js
+++ b/src/textile/attr.js
@@ -178,7 +178,22 @@ function parseAttr ( input, element, endToken ) {
return ( remaining === input ) ? undefined : [ input.length - remaining.length, o ];
}
+function addLineNumber ( jsonmlEntryAttr, options, charPosToLine, charOffset, charPos ) {
+ if ( options.showOriginalLineNumber && charPosToLine ) {
+ charOffset = charOffset || 0;
+ if ( !jsonmlEntryAttr ) {
+ jsonmlEntryAttr = {};
+ }
+ jsonmlEntryAttr['data-line'] = charPosToLine[ charOffset + charPos ];
+ if ( options.cssClassOriginalLineNumber ) {
+ jsonmlEntryAttr['class'] = ( jsonmlEntryAttr['class'] ? jsonmlEntryAttr['class'] + ' ' : '' ) + options.cssClassOriginalLineNumber;
+ }
+ }
+ return jsonmlEntryAttr;
+}
+
module.exports = {
copyAttr: copyAttr,
- parseAttr: parseAttr
+ parseAttr: parseAttr,
+ addLineNumber: addLineNumber
};
diff --git a/src/textile/deflist.js b/src/textile/deflist.js
index dd18d5e..26485e5 100644
--- a/src/textile/deflist.js
+++ b/src/textile/deflist.js
@@ -2,6 +2,8 @@
const ribbon = require( '../ribbon' );
+const { addLineNumber } = require( './attr' );
+
const reDeflist = /^((?:- (?:[^\n]\n?)+?)+:=(?: *\n[^\0]+?=:(?:\n|$)|(?:[^\0]+?(?:$|\n(?=\n|- )))))+/;
const reItem = /^((?:- (?:[^\n]\n?)+?)+):=( *\n[^\0]+?=:\s*(?:\n|$)|(?:[^\0]+?(?:$|\n(?=\n|- ))))/;
@@ -9,7 +11,13 @@ function testDefList ( src ) {
return reDeflist.exec( src );
}
-function parseDefList ( src, options ) {
+function parseDefList ( src, options, charOffset, charPosToLine ) {
+ if ( options.showOriginalLineNumber ) {
+ const removedSrc = src.match( /^\s+/ );
+ if ( removedSrc && removedSrc[0] ) {
+ charOffset += removedSrc[0].length;
+ }
+ }
src = ribbon( src.trim() );
// late loading to get around the lack of non-circular-dependency support in RequireJS
@@ -23,19 +31,45 @@ function parseDefList ( src, options ) {
while ( ( m = reItem.exec( src ) ) ) {
// add terms
- terms = m[1].split( /(?:^|\n)\- / ).slice( 1 );
+ terms = m[1].split( /(?:^|\n)\- / );
+ let localCharOffset = terms[0].length;
+ terms = terms.slice( 1 );
+ let separators = [];
+ if ( options.showOriginalLineNumber ) {
+ separators = m[1].match( /(?:^|\n)\- /g ).slice( 1 );
+ }
while ( terms.length ) {
+ const term = terms.shift();
deflist.push( '\t'
- , [ 'dt' ].concat( parsePhrase( terms.shift().trim(), options ) )
+ , [ 'dt' ].concat(
+ addLineNumber({}, options, charPosToLine, charOffset, src.getPos() + localCharOffset )
+ , parsePhrase( term.trim(), options ) )
, '\n'
);
+ if ( options.showOriginalLineNumber ) {
+ localCharOffset += term.length;
+ // perhaps no separator at the end of the list
+ if ( separators.length ) {
+ localCharOffset += separators.shift().length;
+ }
+ }
}
// add definitions
def = m[2].trim();
+ if ( options.showOriginalLineNumber ) {
+ // rebase local char offset, and add +2 for ':=' between term and definition
+ localCharOffset = m[1].length + 2;
+
+ const removedSrc = m[2].match( /^\s+/ );
+ if ( removedSrc && removedSrc[0] ) {
+ localCharOffset += removedSrc[0].length;
+ }
+ }
deflist.push( '\t'
, [ 'dd' ].concat(
- ( /=:$/.test( def ) )
- ? parseFlow( def.slice( 0, -2 ).trim(), options )
+ addLineNumber({}, options, charPosToLine, charOffset, src.getPos() + localCharOffset )
+ , ( /=:$/.test( def ) )
+ ? parseFlow( def.slice( 0, -2 ).trim(), options, options.showOriginalLineNumber ? charPosToLine[ ( charOffset || 0 ) + localCharOffset + src.getPos() ] : 0 )
: parsePhrase( def, options )
)
, '\n'
diff --git a/src/textile/flow.js b/src/textile/flow.js
index a09c13e..31583bd 100644
--- a/src/textile/flow.js
+++ b/src/textile/flow.js
@@ -9,7 +9,7 @@ const fixLinks = require( '../fixlinks' );
const { parseHtml, tokenize, parseHtmlAttr, singletons, testComment, testOpenTagBlock } = require( '../html' );
const { parsePhrase } = require( './phrase' );
-const { copyAttr, parseAttr } = require( './attr' );
+const { copyAttr, parseAttr, addLineNumber } = require( './attr' );
const { testList, parseList } = require( './list' );
const { testDefList, parseDefList } = require( './deflist' );
const { testTable, parseTable } = require( './table' );
@@ -45,6 +45,7 @@ const reRuler = /^(\-\-\-+|\*\*\*+|___+)(\r?\n\s+|$)/;
const reLinkRef = re.compile( /^\[([^\]]+)\]((?:https?:\/\/|\/)\S+)(?:\s*\n|$)/ );
const reFootnoteDef = /^fn\d+$/;
+const reCleanBegin = /^( *\r?\n)+/;
const hasOwn = Object.prototype.hasOwnProperty;
function extend ( target, ...args ) {
@@ -80,13 +81,52 @@ function paragraph ( s, tag, pba, linebreak, options ) {
return out;
};
-function parseFlow ( src, options ) {
+function computeCharOffset ( src, options, lineOffset ) {
+ if ( options.showOriginalLineNumber ) {
+ lineOffset = lineOffset || 0;
+
+ const removedSrc = src.match( reCleanBegin );
+ if ( removedSrc && removedSrc[0] ) {
+ lineOffset += ( removedSrc[0].match( /\n/g ) || [] ).length;
+ }
+ return lineOffset;
+ }
+ else {
+ return 0;
+ }
+}
+
+function storeCharPosToLine ( src, options, charOffset ) {
+ // FIXME: don't store all chr ?
+ if ( options.showOriginalLineNumber ) {
+ const charPosToLine = [];
+ const realSrc = src.toString();
+ for ( const i in realSrc ) {
+ charPosToLine[ i ] = charOffset;
+ if ( realSrc[ i ] === '\n' ) {
+ charOffset++;
+ }
+ }
+ return charPosToLine;
+ }
+ else {
+ return void 0;
+ }
+}
+
+function parseFlow ( src, options, lineOffset ) {
const list = builder();
let linkRefs;
let m;
- src = ribbon( src.replace( /^( *\r?\n)+/, '' ) );
+ // keep as local variable, for nested calls (->block HTML)
+ const charLineOffset = computeCharOffset( src, options, lineOffset );
+
+ src = ribbon( src.replace( reCleanBegin, '' ) );
+
+ // keep as local variable, for nested calls (->block HTML)
+ const charPosToLine = storeCharPosToLine( src, options, charLineOffset );
// loop
while ( src.valueOf() ) {
@@ -113,6 +153,7 @@ function parseFlow ( src, options ) {
src.advance( pba[0] );
pba = pba[1];
}
+ pba = addLineNumber( pba, options, charPosToLine, 0, src.getSlot() );
if ( ( m = /^\.(\.?)(?:\s|(?=:))/.exec( src ) ) ) {
// FIXME: this whole copyAttr seems rather strange?
// slurp rest of block
@@ -272,27 +313,27 @@ function parseFlow ( src, options ) {
// list
if ( ( m = testList( src ) ) ) {
src.advance( m[0] );
- list.add( parseList( m[0], options ) );
+ list.add( parseList( m[0], options, src.getSlot(), charPosToLine ) );
continue;
}
// definition list
if ( ( m = testDefList( src ) ) ) {
src.advance( m[0] );
- list.add( parseDefList( m[0], options ) );
+ list.add( parseDefList( m[0], options, src.getSlot(), charPosToLine ) );
continue;
}
// table
if ( ( m = testTable( src ) ) ) {
src.advance( m[0] );
- list.add( parseTable( m[1], options ) );
+ list.add( parseTable( m[1], options, src.getSlot(), charPosToLine ) );
continue;
}
// paragraph
m = reBlockNormal.exec( src );
- list.merge( paragraph( m[1], 'p', undefined, '\n', options ) );
+ list.merge( paragraph( m[1], 'p', addLineNumber({}, options, charPosToLine, 0, src.getSlot() ), '\n', options ) );
src.advance( m[0] );
}
diff --git a/src/textile/list.js b/src/textile/list.js
index 94c0bb4..b255550 100644
--- a/src/textile/list.js
+++ b/src/textile/list.js
@@ -3,7 +3,7 @@ const ribbon = require( '../ribbon' );
const re = require( '../re' );
const merge = require( '../merge' );
-const { parseAttr } = require( './attr' );
+const { parseAttr, addLineNumber } = require( './attr' );
const { parsePhrase } = require( './phrase' );
const { txlisthd } = require( './re_ext' );
@@ -23,7 +23,13 @@ function testList ( src ) {
return reList.exec( src );
}
-function parseList ( src, options ) {
+function parseList ( src, options, charOffset, charPosToLine ) {
+ if ( options.showOriginalLineNumber ) {
+ const removedSrc = src.match( /(^|\r?\n)[\t ]+/ );
+ if ( removedSrc && removedSrc[0] ) {
+ charOffset++;
+ }
+ }
src = ribbon( src.replace( /(^|\r?\n)[\t ]+/, '$1' ) );
const stack = [];
@@ -58,6 +64,8 @@ function parseList ( src, options ) {
pba = pba[1];
}
+ pba = addLineNumber( pba, options, charPosToLine, charOffset, src.getPos() );
+
// list control
if ( /^\.\s*$/.test( m[2] ) ) {
listAttr = pba || {};
diff --git a/src/textile/table.js b/src/textile/table.js
index 9d4e03b..5b68eed 100644
--- a/src/textile/table.js
+++ b/src/textile/table.js
@@ -4,7 +4,7 @@ const re = require( '../re' );
const merge = require( '../merge' );
const ribbon = require( '../ribbon' );
-const { parseAttr } = require( './attr' );
+const { parseAttr, addLineNumber } = require( './attr' );
const { parsePhrase } = require( './phrase' );
const { reIndent } = require( '../jsonml' );
@@ -55,7 +55,13 @@ function testTable ( src ) {
return reTable.exec( src );
}
-function parseTable ( src, options ) {
+function parseTable ( src, options, charOffset, charPosToLine ) {
+ if ( options.showOriginalLineNumber ) {
+ const removedSrc = src.match( /^\s+/ );
+ if ( removedSrc && removedSrc[0] ) {
+ charOffset += removedSrc[0].length;
+ }
+ }
src = ribbon( src.trim() );
const rowgroups = [];
@@ -128,7 +134,10 @@ function parseTable ( src, options ) {
if ( m[2] && ( pba = parseAttr( m[2], 'tr' ) ) ) {
// FIXME: requires "\.\s?" -- else what ?
- row.push( pba[1] );
+ row.push( addLineNumber( pba[1], options, charPosToLine, charOffset, src.getPos() ) );
+ }
+ else {
+ row.push( addLineNumber({}, options, charPosToLine, charOffset, src.getPos() ) );
}
tCurr.push( '\n\t\t', row );
diff --git a/test/options_show-original-line-number.js b/test/options_show-original-line-number.js
new file mode 100644
index 0000000..7a8d9d0
--- /dev/null
+++ b/test/options_show-original-line-number.js
@@ -0,0 +1,385 @@
+/* eslint-disable prefer-const, no-multi-str, quotes */
+const test = require( 'tape' );
+const textile = require( '../src' );
+// basic.yml
+
+test( 'paragraphs showing original line number', function ( t ) {
+ let tx = "A single paragraph.\n\n\
+Followed by another.";
+ t.is( textile.convert( tx, { showOriginalLineNumber:true } ),
+ '
A single paragraph.
\n\ +Followed by another.
' ); + t.end(); +}); + +test( 'paragraphs preceded by noise, showing original line number', function ( t ) { + let tx = "\n\nA single paragraph.\n\n\ +Followed by another."; + t.is( textile.convert( tx, { showOriginalLineNumber:true } ), + 'A single paragraph.
\n\ +Followed by another.
' ); + t.end(); +}); + +test( 'paragraphs preceded by noise and 3 lines offset, showing original line number', function ( t ) { + let tx = "\n\nA single paragraph.\n\n\ +Followed by another."; + t.is( textile.convert( tx, { showOriginalLineNumber:true, lineOffset:3 } ), + 'A single paragraph.
\n\ +Followed by another.
' ); + t.end(); +}); + +test( 'Headers, paragraphs, and elements, showing original line number', function ( t ) { + let tx = `h1. level 1 heading + +h2. level 2 heading + +h3. level 3 heading + +h4. level 4 heading + +bq. this is blockquoted text. +With a seconde line. + +fn1. footnote 1, +With a second line. + +fn2. footnote 2, + +This text references a footnote[1]. + +_emphasis_ +*strong* +??citation?? +-deleted text- ++inserted text+ +^superscript^ +~subscript~ +%span% + +p(class). paragraph with a classname + +p(#id). paragraph with an ID + +p{color:red}. paragrah with a CSS style + +p[fr]. paragraphe en français + +p<. right aligned paragraph + +p>. left aligned paragraph + +p=. centered aligned paragraph + +p<>. justified text paragraph + +"linktext":url + +!imageurl! + +ABBR(Abbreviation)`; + t.is( textile.convert( tx, { showOriginalLineNumber:true, lineOffset:1 } ), + `++this is blockquoted text.
+
+With a seconde line.
1 footnote 1,
+With a second line.
2 footnote 2,
+This text references a footnote1.
+emphasis
+strong
+citation
+deleted text
+inserted text
+superscript
+subscript
+span
paragraph with a classname
+paragraph with an ID
+paragrah with a CSS style
+paragraphe en français
+right aligned paragraph
+left aligned paragraph
+centered aligned paragraph
+justified text paragraph
+ +ABBR
` ); + t.end(); +}); + +test( 'Lists, showing original line number', function ( t ) { + let tx = `# numbered list item 1 +with a second line +# numbered list item 2 +with a second line +## with a sub item 1 +and a seconde line +## with a sub item 2 + +* bulleted list first item +with a second line +* bulleted list second item +** with a sub item 1 +with a seconde line +** with a sub item 2`; + t.is( textile.convert( tx, { showOriginalLineNumber:true, lineOffset:1 } ), + `| head | +\t\ttable | +\t\trow | +\t
|---|---|---|
| a multiline +value |
+\t\ttable | +\t\trow 1 | +\t
| a | +\t\ttable | +\t\trow 2 | +\t
Beginning of
+a multi-lines
+definition
++this is blockquoted text.
+
+With a seconde line.
1 footnote 1,
+With a second line.
2 footnote 2,
+This text references a footnote1.
+emphasis
+strong
+citation
+deleted text
+inserted text
+superscript
+subscript
+span
paragraph with a classname
+paragraph with an ID
+paragrah with a CSS style
+paragraphe en français
+right aligned paragraph
+left aligned paragraph
+centered aligned paragraph
+justified text paragraph
+ +ABBR
` ); + t.end(); +}); + +test( 'Lists, showing original line number and CSS class name', function ( t ) { + let tx = `# numbered list item 1 +with a second line +# numbered list item 2 +with a second line +## with a sub item 1 +and a seconde line +## with a sub item 2 + +* bulleted list first item +with a second line +* bulleted list second item +** with a sub item 1 +with a seconde line +** with a sub item 2`; + t.is( textile.convert( tx, { showOriginalLineNumber:true, lineOffset:1, cssClassOriginalLineNumber:'code-line' } ), + `| head | +\t\ttable | +\t\trow | +\t
|---|---|---|
| a multiline +value |
+\t\ttable | +\t\trow 1 | +\t
| a | +\t\ttable | +\t\trow 2 | +\t
Beginning of
+a multi-lines
+definition