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 } ), + `

level 1 heading

+

level 2 heading

+

level 3 heading

+

level 4 heading

+
+

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

+

linktext

+

+

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 } ), + `
    +\t
  1. numbered list item 1
    +with a second line
  2. +\t
  3. numbered list item 2
    +with a second line +\t
      +\t\t
    1. with a sub item 1
      +and a seconde line
    2. +\t\t
    3. with a sub item 2
    4. +\t
  4. +
+` ); + t.end(); +}); + +test( 'Table, showing original line number', function ( t ) { + let tx = `|_. head |_. table |_. row | +| a multiline +value| table | row 1 | +| a | table | row 2 |`; + t.is( textile.convert( tx, { showOriginalLineNumber:true, lineOffset:1 } ), + ` +\t +\t\t +\t\t +\t\t +\t +\t +\t\t +\t\t +\t\t +\t +\t +\t\t +\t\t +\t\t +\t +
head table row
a multiline
+value
table row 1
a table row 2
` ); + t.end(); +}); + +test( 'Definition list, showing original line number', function ( t ) { + let tx = `- Word1 +Word1 bis +Word1 ter := Definition 1. +- Word2 +- Word2 bis +- Word2 ter := +Beginning of +a multi-lines +definition =:`; + t.is( textile.convert( tx, { showOriginalLineNumber:true, lineOffset:1 } ), + `
+\t
Word1
+Word1 bis
+Word1 ter
+\t
Definition 1.
+\t
Word2
+\t
Word2 bis
+\t
Word2 ter
+\t

Beginning of
+a multi-lines
+definition

+
` ); + t.end(); +}); + +test( 'Headers, paragraphs, and elements, showing original line number and CSS class name', 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, cssClassOriginalLineNumber:'code-line' } ), + `

level 1 heading

+

level 2 heading

+

level 3 heading

+

level 4 heading

+
+

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

+

linktext

+

+

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' } ), + `
    +\t
  1. numbered list item 1
    +with a second line
  2. +\t
  3. numbered list item 2
    +with a second line +\t
      +\t\t
    1. with a sub item 1
      +and a seconde line
    2. +\t\t
    3. with a sub item 2
    4. +\t
  4. +
+` ); + t.end(); +}); + +test( 'Table, showing original line number and CSS class name', function ( t ) { + let tx = `|_. head |_. table |_. row | +| a multiline +value| table | row 1 | +| a | table | row 2 |`; + t.is( textile.convert( tx, { showOriginalLineNumber:true, lineOffset:1, cssClassOriginalLineNumber:'code-line' } ), + ` +\t +\t\t +\t\t +\t\t +\t +\t +\t\t +\t\t +\t\t +\t +\t +\t\t +\t\t +\t\t +\t +
head table row
a multiline
+value
table row 1
a table row 2
` ); + t.end(); +}); + +test( 'Definition list, showing original line number and CSS class name', function ( t ) { + let tx = `- Word1 +Word1 bis +Word1 ter := Definition 1. +- Word2 +- Word2 bis +- Word2 ter := +Beginning of +a multi-lines +definition =:`; + t.is( textile.convert( tx, { showOriginalLineNumber:true, lineOffset:1, cssClassOriginalLineNumber:'code-line' } ), + `
+\t
Word1
+Word1 bis
+Word1 ter
+\t
Definition 1.
+\t
Word2
+\t
Word2 bis
+\t
Word2 ter
+\t

Beginning of
+a multi-lines
+definition

+
` ); + t.end(); +}); \ No newline at end of file