micromark-extension-gfm-table
Advanced tools
Comparing version 1.0.5 to 1.0.6
@@ -1,4 +0,9 @@ | ||
/** @type {HtmlExtension} */ | ||
/** | ||
* Extension for `micromark` that can be passed in `htmlExtensions` to support | ||
* GFM tables when serializing to HTML. | ||
* | ||
* @type {HtmlExtension} | ||
*/ | ||
export const gfmTableHtml: HtmlExtension | ||
export type HtmlExtension = import('micromark-util-types').HtmlExtension | ||
export type Align = import('./syntax.js').Align | ||
export type Align = import('./infer.js').Align |
/** | ||
* @typedef {import('micromark-util-types').HtmlExtension} HtmlExtension | ||
* @typedef {import('./syntax.js').Align} Align | ||
*/ | ||
/** | ||
* @typedef {import('./infer.js').Align} Align | ||
*/ | ||
const alignment = { | ||
@@ -13,3 +16,11 @@ none: '', | ||
/** @type {HtmlExtension} */ | ||
// To do: next major: expose functions. | ||
// To do: next major: use `infer` here, when all events are exposed. | ||
/** | ||
* Extension for `micromark` that can be passed in `htmlExtensions` to support | ||
* GFM tables when serializing to HTML. | ||
* | ||
* @type {HtmlExtension} | ||
*/ | ||
export const gfmTableHtml = { | ||
@@ -26,4 +37,2 @@ enter: { | ||
tableBody() { | ||
// Clear slurping line ending from the delimiter row. | ||
this.setData('slurpOneLineEnding') | ||
this.tag('<tbody>') | ||
@@ -80,4 +89,5 @@ }, | ||
this.setData('tableAlign') | ||
// If there was no table body, make sure the slurping from the delimiter row | ||
// is cleared. | ||
// Note: we don’t set `slurpAllLineEndings` anymore, in delimiter rows, | ||
// but we do need to reset it to match a funky newline GH generates for | ||
// list items combined with tables. | ||
this.setData('slurpAllLineEndings') | ||
@@ -108,4 +118,2 @@ this.lineEndingIfNeeded() | ||
this.tag('</thead>') | ||
this.setData('slurpOneLineEnding', true) | ||
// Slurp the line ending from the delimiter row. | ||
}, | ||
@@ -112,0 +120,0 @@ tableHeader() { |
@@ -1,8 +0,23 @@ | ||
/** @type {Extension} */ | ||
/** | ||
* Extension for `micromark` that can be passed in `extensions` to enable GFM | ||
* table syntax. | ||
* | ||
* @type {Extension} | ||
*/ | ||
export const gfmTable: Extension | ||
export type Event = import('micromark-util-types').Event | ||
export type Extension = import('micromark-util-types').Extension | ||
export type Point = import('micromark-util-types').Point | ||
export type Resolver = import('micromark-util-types').Resolver | ||
export type Tokenizer = import('micromark-util-types').Tokenizer | ||
export type State = import('micromark-util-types').State | ||
export type Token = import('micromark-util-types').Token | ||
export type Align = 'left' | 'center' | 'right' | 'none' | ||
export type TokenizeContext = import('micromark-util-types').TokenizeContext | ||
export type Tokenizer = import('micromark-util-types').Tokenizer | ||
/** | ||
* Cell info. | ||
*/ | ||
export type Range = [number, number, number, number] | ||
/** | ||
* Where we are: `1` for head row, `2` for delimiter row, `3` for body row. | ||
*/ | ||
export type RowKind = 0 | 1 | 2 | 3 |
/** | ||
* @typedef {import('micromark-util-types').Event} Event | ||
* @typedef {import('micromark-util-types').Extension} Extension | ||
* @typedef {import('micromark-util-types').Point} Point | ||
* @typedef {import('micromark-util-types').Resolver} Resolver | ||
* @typedef {import('micromark-util-types').Tokenizer} Tokenizer | ||
* @typedef {import('micromark-util-types').State} State | ||
* @typedef {import('micromark-util-types').Token} Token | ||
* @typedef {import('micromark-util-types').TokenizeContext} TokenizeContext | ||
* @typedef {import('micromark-util-types').Tokenizer} Tokenizer | ||
*/ | ||
/** | ||
* @typedef {'left'|'center'|'right'|'none'} Align | ||
* @typedef {[number, number, number, number]} Range | ||
* Cell info. | ||
* | ||
* @typedef {0 | 1 | 2 | 3} RowKind | ||
* Where we are: `1` for head row, `2` for delimiter row, `3` for body row. | ||
*/ | ||
@@ -23,220 +30,204 @@ | ||
import {types} from 'micromark-util-symbol/types.js' | ||
import {EditMap} from './edit-map.js' | ||
import {gfmTableAlign} from './infer.js' | ||
/** @type {Extension} */ | ||
// To do: next major: expose functions. | ||
/** | ||
* Extension for `micromark` that can be passed in `extensions` to enable GFM | ||
* table syntax. | ||
* | ||
* @type {Extension} | ||
*/ | ||
export const gfmTable = { | ||
flow: {null: {tokenize: tokenizeTable, resolve: resolveTable}} | ||
flow: {null: {tokenize: tokenizeTable, resolveAll: resolveTable}} | ||
} | ||
const nextPrefixedOrBlank = { | ||
tokenize: tokenizeNextPrefixedOrBlank, | ||
partial: true | ||
} | ||
/** | ||
* @this {TokenizeContext} | ||
* @type {Tokenizer} | ||
*/ | ||
function tokenizeTable(effects, ok, nok) { | ||
const self = this | ||
let size = 0 | ||
let sizeB = 0 | ||
/** @type {boolean | undefined} */ | ||
let seen | ||
/** @type {Resolver} */ | ||
function resolveTable(events, context) { | ||
let index = -1 | ||
/** @type {boolean|undefined} */ | ||
let inHead | ||
/** @type {boolean|undefined} */ | ||
let inDelimiterRow | ||
/** @type {boolean|undefined} */ | ||
let inRow | ||
/** @type {number|undefined} */ | ||
let contentStart | ||
/** @type {number|undefined} */ | ||
let contentEnd | ||
/** @type {number|undefined} */ | ||
let cellStart | ||
/** @type {boolean|undefined} */ | ||
let seenCellInRow | ||
return start | ||
while (++index < events.length) { | ||
const token = events[index][1] | ||
/** | ||
* Start of a GFM table. | ||
* | ||
* If there is a valid table row or table head before, then we try to parse | ||
* another row. | ||
* Otherwise, we try to parse a head. | ||
* | ||
* ```markdown | ||
* > | | a | | ||
* ^ | ||
* | | - | | ||
* > | | b | | ||
* ^ | ||
* ``` | ||
* @type {State} | ||
*/ | ||
function start(code) { | ||
let index = self.events.length - 1 | ||
if (inRow) { | ||
if (token.type === 'temporaryTableCellContent') { | ||
contentStart = contentStart || index | ||
contentEnd = index | ||
} | ||
while (index > -1) { | ||
const type = self.events[index][1].type | ||
if ( | ||
// Combine separate content parts into one. | ||
(token.type === 'tableCellDivider' || token.type === 'tableRow') && | ||
contentEnd | ||
) { | ||
assert( | ||
contentStart, | ||
'expected `contentStart` to be defined if `contentEnd` is' | ||
) | ||
const content = { | ||
type: 'tableContent', | ||
start: events[contentStart][1].start, | ||
end: events[contentEnd][1].end | ||
} | ||
/** @type {Token} */ | ||
const text = { | ||
type: types.chunkText, | ||
start: content.start, | ||
end: content.end, | ||
// @ts-expect-error It’s fine. | ||
contentType: constants.contentTypeText | ||
} | ||
assert( | ||
contentStart, | ||
'expected `contentStart` to be defined if `contentEnd` is' | ||
) | ||
events.splice( | ||
contentStart, | ||
contentEnd - contentStart + 1, | ||
['enter', content, context], | ||
['enter', text, context], | ||
['exit', text, context], | ||
['exit', content, context] | ||
) | ||
index -= contentEnd - contentStart - 3 | ||
contentStart = undefined | ||
contentEnd = undefined | ||
} | ||
type === types.lineEnding || | ||
// Note: markdown-rs uses `whitespace` instead of `linePrefix` | ||
type === types.linePrefix | ||
) | ||
index-- | ||
else break | ||
} | ||
if ( | ||
events[index][0] === 'exit' && | ||
cellStart !== undefined && | ||
cellStart + (seenCellInRow ? 0 : 1) < index && | ||
(token.type === 'tableCellDivider' || | ||
(token.type === 'tableRow' && | ||
(cellStart + 3 < index || | ||
events[cellStart][1].type !== types.whitespace))) | ||
) { | ||
const cell = { | ||
type: inDelimiterRow | ||
? 'tableDelimiter' | ||
: inHead | ||
? 'tableHeader' | ||
: 'tableData', | ||
start: events[cellStart][1].start, | ||
end: events[index][1].end | ||
} | ||
events.splice(index + (token.type === 'tableCellDivider' ? 1 : 0), 0, [ | ||
'exit', | ||
cell, | ||
context | ||
]) | ||
events.splice(cellStart, 0, ['enter', cell, context]) | ||
index += 2 | ||
cellStart = index + 1 | ||
seenCellInRow = true | ||
} | ||
const tail = index > -1 ? self.events[index][1].type : null | ||
if (token.type === 'tableRow') { | ||
inRow = events[index][0] === 'enter' | ||
const next = | ||
tail === 'tableHead' || tail === 'tableRow' ? bodyRowStart : headRowBefore | ||
if (inRow) { | ||
cellStart = index + 1 | ||
seenCellInRow = false | ||
} | ||
// Don’t allow lazy body rows. | ||
if (next === bodyRowStart && self.parser.lazy[self.now().line]) { | ||
return nok(code) | ||
} | ||
if (token.type === 'tableDelimiterRow') { | ||
inDelimiterRow = events[index][0] === 'enter' | ||
if (inDelimiterRow) { | ||
cellStart = index + 1 | ||
seenCellInRow = false | ||
} | ||
} | ||
if (token.type === 'tableHead') { | ||
inHead = events[index][0] === 'enter' | ||
} | ||
return next(code) | ||
} | ||
return events | ||
} | ||
/** @type {Tokenizer} */ | ||
function tokenizeTable(effects, ok, nok) { | ||
const self = this | ||
/** @type {Array<Align>} */ | ||
const align = [] | ||
let tableHeaderCount = 0 | ||
/** @type {boolean|undefined} */ | ||
let seenDelimiter | ||
/** @type {boolean|undefined} */ | ||
let hasDash | ||
return start | ||
/** @type {State} */ | ||
function start(code) { | ||
// @ts-expect-error Custom. | ||
effects.enter('table')._align = align | ||
/** | ||
* Before table head row. | ||
* | ||
* ```markdown | ||
* > | | a | | ||
* ^ | ||
* | | - | | ||
* | | b | | ||
* ``` | ||
* | ||
* @type {State} | ||
*/ | ||
function headRowBefore(code) { | ||
effects.enter('tableHead') | ||
effects.enter('tableRow') | ||
return headRowStart(code) | ||
} | ||
// If we start with a pipe, we open a cell marker. | ||
/** | ||
* Before table head row, after whitespace. | ||
* | ||
* ```markdown | ||
* > | | a | | ||
* ^ | ||
* | | - | | ||
* | | b | | ||
* ``` | ||
* | ||
* @type {State} | ||
*/ | ||
function headRowStart(code) { | ||
if (code === codes.verticalBar) { | ||
return cellDividerHead(code) | ||
return headRowBreak(code) | ||
} | ||
tableHeaderCount++ | ||
effects.enter('temporaryTableCellContent') | ||
// Can’t be space or eols at the start of a construct, so we’re in a cell. | ||
assert(!markdownLineEndingOrSpace(code), 'expected non-space') | ||
return inCellContentHead(code) | ||
} | ||
// To do: micromark-js should let us parse our own whitespace in extensions, | ||
// like `markdown-rs`: | ||
// | ||
// ```js | ||
// // 4+ spaces. | ||
// if (markdownSpace(code)) { | ||
// return nok(code) | ||
// } | ||
// ``` | ||
/** @type {State} */ | ||
function cellDividerHead(code) { | ||
assert(code === codes.verticalBar, 'expected `|`') | ||
effects.enter('tableCellDivider') | ||
effects.consume(code) | ||
effects.exit('tableCellDivider') | ||
seenDelimiter = true | ||
return cellBreakHead | ||
seen = true | ||
// Count the first character, that isn’t a pipe, double. | ||
sizeB += 1 | ||
return headRowBreak(code) | ||
} | ||
/** @type {State} */ | ||
function cellBreakHead(code) { | ||
if (code === codes.eof || markdownLineEnding(code)) { | ||
return atRowEndHead(code) | ||
/** | ||
* At break in table head row. | ||
* | ||
* ```markdown | ||
* > | | a | | ||
* ^ | ||
* ^ | ||
* ^ | ||
* | | - | | ||
* | | b | | ||
* ``` | ||
* | ||
* @type {State} | ||
*/ | ||
function headRowBreak(code) { | ||
if (code === codes.eof) { | ||
// Note: in `markdown-rs`, we need to reset, in `micromark-js` we don‘t. | ||
return nok(code) | ||
} | ||
if (markdownLineEnding(code)) { | ||
// If anything other than one pipe (ignoring whitespace) was used, it’s fine. | ||
if (sizeB > 1) { | ||
sizeB = 0 | ||
// To do: check if this works. | ||
// Feel free to interrupt: | ||
self.interrupt = true | ||
effects.exit('tableRow') | ||
effects.enter(types.lineEnding) | ||
effects.consume(code) | ||
effects.exit(types.lineEnding) | ||
return headDelimiterStart | ||
} | ||
// Note: in `markdown-rs`, we need to reset, in `micromark-js` we don‘t. | ||
return nok(code) | ||
} | ||
if (markdownSpace(code)) { | ||
effects.enter(types.whitespace) | ||
effects.consume(code) | ||
return inWhitespaceHead | ||
// To do: check if this is fine. | ||
// effects.attempt(State::Next(StateName::GfmTableHeadRowBreak), State::Nok) | ||
// State::Retry(space_or_tab(tokenizer)) | ||
return factorySpace(effects, headRowBreak, types.whitespace)(code) | ||
} | ||
if (seenDelimiter) { | ||
seenDelimiter = undefined | ||
tableHeaderCount++ | ||
sizeB += 1 | ||
if (seen) { | ||
seen = false | ||
// Header cell count. | ||
size += 1 | ||
} | ||
if (code === codes.verticalBar) { | ||
return cellDividerHead(code) | ||
} | ||
// Anything else is cell content. | ||
effects.enter('temporaryTableCellContent') | ||
return inCellContentHead(code) | ||
} | ||
/** @type {State} */ | ||
function inWhitespaceHead(code) { | ||
if (markdownSpace(code)) { | ||
effects.enter('tableCellDivider') | ||
effects.consume(code) | ||
return inWhitespaceHead | ||
effects.exit('tableCellDivider') | ||
// Whether a delimiter was seen. | ||
seen = true | ||
return headRowBreak | ||
} | ||
effects.exit(types.whitespace) | ||
return cellBreakHead(code) | ||
// Anything else is cell data. | ||
effects.enter(types.data) | ||
return headRowData(code) | ||
} | ||
/** @type {State} */ | ||
function inCellContentHead(code) { | ||
// EOF, whitespace, pipe | ||
/** | ||
* In table head row data. | ||
* | ||
* ```markdown | ||
* > | | a | | ||
* ^ | ||
* | | - | | ||
* | | b | | ||
* ``` | ||
* | ||
* @type {State} | ||
*/ | ||
function headRowData(code) { | ||
if ( | ||
@@ -247,254 +238,358 @@ code === codes.eof || | ||
) { | ||
effects.exit('temporaryTableCellContent') | ||
return cellBreakHead(code) | ||
effects.exit(types.data) | ||
return headRowBreak(code) | ||
} | ||
effects.consume(code) | ||
return code === codes.backslash | ||
? inCellContentEscapeHead | ||
: inCellContentHead | ||
return code === codes.backslash ? headRowEscape : headRowData | ||
} | ||
/** @type {State} */ | ||
function inCellContentEscapeHead(code) { | ||
/** | ||
* In table head row escape. | ||
* | ||
* ```markdown | ||
* > | | a\-b | | ||
* ^ | ||
* | | ---- | | ||
* | | c | | ||
* ``` | ||
* | ||
* @type {State} | ||
*/ | ||
function headRowEscape(code) { | ||
if (code === codes.backslash || code === codes.verticalBar) { | ||
effects.consume(code) | ||
return inCellContentHead | ||
return headRowData | ||
} | ||
// Anything else. | ||
return inCellContentHead(code) | ||
return headRowData(code) | ||
} | ||
/** @type {State} */ | ||
function atRowEndHead(code) { | ||
if (code === codes.eof) { | ||
/** | ||
* Before delimiter row. | ||
* | ||
* ```markdown | ||
* | | a | | ||
* > | | - | | ||
* ^ | ||
* | | b | | ||
* ``` | ||
* | ||
* @type {State} | ||
*/ | ||
function headDelimiterStart(code) { | ||
// Reset `interrupt`. | ||
self.interrupt = false | ||
// Note: in `markdown-rs`, we need to handle piercing here too. | ||
if (self.parser.lazy[self.now().line]) { | ||
return nok(code) | ||
} | ||
assert(markdownLineEnding(code), 'expected eol') | ||
effects.exit('tableRow') | ||
effects.exit('tableHead') | ||
const originalInterrupt = self.interrupt | ||
self.interrupt = true | ||
return effects.attempt( | ||
{tokenize: tokenizeRowEnd, partial: true}, | ||
function (code) { | ||
self.interrupt = originalInterrupt | ||
effects.enter('tableDelimiterRow') | ||
return atDelimiterRowBreak(code) | ||
}, | ||
function (code) { | ||
self.interrupt = originalInterrupt | ||
return nok(code) | ||
} | ||
)(code) | ||
} | ||
effects.enter('tableDelimiterRow') | ||
// Track if we’ve seen a `:` or `|`. | ||
seen = false | ||
/** @type {State} */ | ||
function atDelimiterRowBreak(code) { | ||
if (code === codes.eof || markdownLineEnding(code)) { | ||
return rowEndDelimiter(code) | ||
} | ||
if (markdownSpace(code)) { | ||
effects.enter(types.whitespace) | ||
effects.consume(code) | ||
return inWhitespaceDelimiter | ||
return factorySpace( | ||
effects, | ||
headDelimiterBefore, | ||
types.linePrefix, | ||
self.parser.constructs.disable.null.includes('codeIndented') | ||
? undefined | ||
: constants.tabSize | ||
)(code) | ||
} | ||
if (code === codes.dash) { | ||
effects.enter('tableDelimiterFiller') | ||
effects.consume(code) | ||
hasDash = true | ||
align.push('none') | ||
return inFillerDelimiter | ||
} | ||
return headDelimiterBefore(code) | ||
} | ||
if (code === codes.colon) { | ||
effects.enter('tableDelimiterAlignment') | ||
effects.consume(code) | ||
effects.exit('tableDelimiterAlignment') | ||
align.push('left') | ||
return afterLeftAlignment | ||
/** | ||
* Before delimiter row, after optional whitespace. | ||
* | ||
* Reused when a `|` is found later, to parse another cell. | ||
* | ||
* ```markdown | ||
* | | a | | ||
* > | | - | | ||
* ^ | ||
* | | b | | ||
* ``` | ||
* | ||
* @type {State} | ||
*/ | ||
function headDelimiterBefore(code) { | ||
if (code === codes.dash || code === codes.colon) { | ||
return headDelimiterValueBefore(code) | ||
} | ||
// If we start with a pipe, we open a cell marker. | ||
if (code === codes.verticalBar) { | ||
seen = true | ||
// If we start with a pipe, we open a cell marker. | ||
effects.enter('tableCellDivider') | ||
effects.consume(code) | ||
effects.exit('tableCellDivider') | ||
return atDelimiterRowBreak | ||
return headDelimiterCellBefore | ||
} | ||
return nok(code) | ||
// More whitespace / empty row not allowed at start. | ||
return headDelimiterNok(code) | ||
} | ||
/** @type {State} */ | ||
function inWhitespaceDelimiter(code) { | ||
/** | ||
* After `|`, before delimiter cell. | ||
* | ||
* ```markdown | ||
* | | a | | ||
* > | | - | | ||
* ^ | ||
* ``` | ||
* | ||
* @type {State} | ||
*/ | ||
function headDelimiterCellBefore(code) { | ||
if (markdownSpace(code)) { | ||
effects.consume(code) | ||
return inWhitespaceDelimiter | ||
return factorySpace( | ||
effects, | ||
headDelimiterValueBefore, | ||
types.whitespace | ||
)(code) | ||
} | ||
effects.exit(types.whitespace) | ||
return atDelimiterRowBreak(code) | ||
return headDelimiterValueBefore(code) | ||
} | ||
/** @type {State} */ | ||
function inFillerDelimiter(code) { | ||
if (code === codes.dash) { | ||
/** | ||
* Before delimiter cell value. | ||
* | ||
* ```markdown | ||
* | | a | | ||
* > | | - | | ||
* ^ | ||
* ``` | ||
* | ||
* @type {State} | ||
*/ | ||
function headDelimiterValueBefore(code) { | ||
// Align: left. | ||
if (code === codes.colon) { | ||
sizeB += 1 | ||
seen = true | ||
effects.enter('tableDelimiterMarker') | ||
effects.consume(code) | ||
return inFillerDelimiter | ||
effects.exit('tableDelimiterMarker') | ||
return headDelimiterLeftAlignmentAfter | ||
} | ||
effects.exit('tableDelimiterFiller') | ||
// Align: none. | ||
if (code === codes.dash) { | ||
sizeB += 1 | ||
// To do: seems weird that this *isn’t* left aligned, but that state is used? | ||
return headDelimiterLeftAlignmentAfter(code) | ||
} | ||
if (code === codes.colon) { | ||
effects.enter('tableDelimiterAlignment') | ||
effects.consume(code) | ||
effects.exit('tableDelimiterAlignment') | ||
align[align.length - 1] = | ||
align[align.length - 1] === 'left' ? 'center' : 'right' | ||
return afterRightAlignment | ||
if (code === codes.eof || markdownLineEnding(code)) { | ||
return headDelimiterCellAfter(code) | ||
} | ||
return atDelimiterRowBreak(code) | ||
return headDelimiterNok(code) | ||
} | ||
/** @type {State} */ | ||
function afterLeftAlignment(code) { | ||
/** | ||
* After delimiter cell left alignment marker. | ||
* | ||
* ```markdown | ||
* | | a | | ||
* > | | :- | | ||
* ^ | ||
* ``` | ||
* | ||
* @type {State} | ||
*/ | ||
function headDelimiterLeftAlignmentAfter(code) { | ||
if (code === codes.dash) { | ||
effects.enter('tableDelimiterFiller') | ||
effects.consume(code) | ||
hasDash = true | ||
return inFillerDelimiter | ||
return headDelimiterFiller(code) | ||
} | ||
// Anything else is not ok. | ||
return nok(code) | ||
// Anything else is not ok after the left-align colon. | ||
return headDelimiterNok(code) | ||
} | ||
/** @type {State} */ | ||
function afterRightAlignment(code) { | ||
if (code === codes.eof || markdownLineEnding(code)) { | ||
return rowEndDelimiter(code) | ||
/** | ||
* In delimiter cell filler. | ||
* | ||
* ```markdown | ||
* | | a | | ||
* > | | - | | ||
* ^ | ||
* ``` | ||
* | ||
* @type {State} | ||
*/ | ||
function headDelimiterFiller(code) { | ||
if (code === codes.dash) { | ||
effects.consume(code) | ||
return headDelimiterFiller | ||
} | ||
if (markdownSpace(code)) { | ||
effects.enter(types.whitespace) | ||
// Align is `center` if it was `left`, `right` otherwise. | ||
if (code === codes.colon) { | ||
seen = true | ||
effects.exit('tableDelimiterFiller') | ||
effects.enter('tableDelimiterMarker') | ||
effects.consume(code) | ||
return inWhitespaceDelimiter | ||
effects.exit('tableDelimiterMarker') | ||
return headDelimiterRightAlignmentAfter | ||
} | ||
// `|` | ||
if (code === codes.verticalBar) { | ||
effects.enter('tableCellDivider') | ||
effects.consume(code) | ||
effects.exit('tableCellDivider') | ||
return atDelimiterRowBreak | ||
effects.exit('tableDelimiterFiller') | ||
return headDelimiterRightAlignmentAfter(code) | ||
} | ||
/** | ||
* After delimiter cell right alignment marker. | ||
* | ||
* ```markdown | ||
* | | a | | ||
* > | | -: | | ||
* ^ | ||
* ``` | ||
* | ||
* @type {State} | ||
*/ | ||
function headDelimiterRightAlignmentAfter(code) { | ||
if (markdownSpace(code)) { | ||
return factorySpace( | ||
effects, | ||
headDelimiterCellAfter, | ||
types.whitespace | ||
)(code) | ||
} | ||
return nok(code) | ||
return headDelimiterCellAfter(code) | ||
} | ||
/** @type {State} */ | ||
function rowEndDelimiter(code) { | ||
effects.exit('tableDelimiterRow') | ||
// Exit if there was no dash at all, or if the header cell count is not the | ||
// delimiter cell count. | ||
if (!hasDash || tableHeaderCount !== align.length) { | ||
return nok(code) | ||
/** | ||
* After delimiter cell. | ||
* | ||
* ```markdown | ||
* | | a | | ||
* > | | -: | | ||
* ^ | ||
* ``` | ||
* | ||
* @type {State} | ||
*/ | ||
function headDelimiterCellAfter(code) { | ||
if (code === codes.verticalBar) { | ||
return headDelimiterBefore(code) | ||
} | ||
if (code === codes.eof) { | ||
return tableClose(code) | ||
if (code === codes.eof || markdownLineEnding(code)) { | ||
// Exit when: | ||
// * there was no `:` or `|` at all (it’s a thematic break or setext | ||
// underline instead) | ||
// * the header cell count is not the delimiter cell count | ||
if (!seen || size !== sizeB) { | ||
return headDelimiterNok(code) | ||
} | ||
// Note: in markdown-rs`, a reset is needed here. | ||
effects.exit('tableDelimiterRow') | ||
effects.exit('tableHead') | ||
// To do: in `markdown-rs`, resolvers need to be registered manually. | ||
// effects.register_resolver(ResolveName::GfmTable) | ||
return ok(code) | ||
} | ||
assert(markdownLineEnding(code), 'expected eol') | ||
return effects.check( | ||
nextPrefixedOrBlank, | ||
tableClose, | ||
effects.attempt( | ||
{tokenize: tokenizeRowEnd, partial: true}, | ||
factorySpace(effects, bodyStart, types.linePrefix, constants.tabSize), | ||
tableClose | ||
) | ||
)(code) | ||
return headDelimiterNok(code) | ||
} | ||
/** @type {State} */ | ||
function tableClose(code) { | ||
effects.exit('table') | ||
return ok(code) | ||
/** | ||
* In delimiter row, at a disallowed byte. | ||
* | ||
* ```markdown | ||
* | | a | | ||
* > | | x | | ||
* ^ | ||
* ``` | ||
* | ||
* @type {State} | ||
*/ | ||
function headDelimiterNok(code) { | ||
// Note: in `markdown-rs`, we need to reset, in `micromark-js` we don‘t. | ||
return nok(code) | ||
} | ||
/** @type {State} */ | ||
function bodyStart(code) { | ||
effects.enter('tableBody') | ||
return rowStartBody(code) | ||
/** | ||
* Before table body row. | ||
* | ||
* ```markdown | ||
* | | a | | ||
* | | - | | ||
* > | | b | | ||
* ^ | ||
* ``` | ||
* | ||
* @type {State} | ||
*/ | ||
function bodyRowStart(code) { | ||
// Note: in `markdown-rs` we need to manually take care of a prefix, | ||
// but in `micromark-js` that is done for us, so if we’re here, we’re | ||
// never at whitespace. | ||
effects.enter('tableRow') | ||
return bodyRowBreak(code) | ||
} | ||
/** @type {State} */ | ||
function rowStartBody(code) { | ||
effects.enter('tableRow') | ||
// If we start with a pipe, we open a cell marker. | ||
/** | ||
* At break in table body row. | ||
* | ||
* ```markdown | ||
* | | a | | ||
* | | - | | ||
* > | | b | | ||
* ^ | ||
* ^ | ||
* ^ | ||
* ``` | ||
* | ||
* @type {State} | ||
*/ | ||
function bodyRowBreak(code) { | ||
if (code === codes.verticalBar) { | ||
return cellDividerBody(code) | ||
effects.enter('tableCellDivider') | ||
effects.consume(code) | ||
effects.exit('tableCellDivider') | ||
return bodyRowBreak | ||
} | ||
effects.enter('temporaryTableCellContent') | ||
// Can’t be space or eols at the start of a construct, so we’re in a cell. | ||
return inCellContentBody(code) | ||
} | ||
/** @type {State} */ | ||
function cellDividerBody(code) { | ||
assert(code === codes.verticalBar, 'expected `|`') | ||
effects.enter('tableCellDivider') | ||
effects.consume(code) | ||
effects.exit('tableCellDivider') | ||
return cellBreakBody | ||
} | ||
/** @type {State} */ | ||
function cellBreakBody(code) { | ||
if (code === codes.eof || markdownLineEnding(code)) { | ||
return atRowEndBody(code) | ||
effects.exit('tableRow') | ||
return ok(code) | ||
} | ||
if (markdownSpace(code)) { | ||
effects.enter(types.whitespace) | ||
effects.consume(code) | ||
return inWhitespaceBody | ||
return factorySpace(effects, bodyRowBreak, types.whitespace)(code) | ||
} | ||
// `|` | ||
if (code === codes.verticalBar) { | ||
return cellDividerBody(code) | ||
} | ||
// Anything else is cell content. | ||
effects.enter('temporaryTableCellContent') | ||
return inCellContentBody(code) | ||
effects.enter(types.data) | ||
return bodyRowData(code) | ||
} | ||
/** @type {State} */ | ||
function inWhitespaceBody(code) { | ||
if (markdownSpace(code)) { | ||
effects.consume(code) | ||
return inWhitespaceBody | ||
} | ||
effects.exit(types.whitespace) | ||
return cellBreakBody(code) | ||
} | ||
/** @type {State} */ | ||
function inCellContentBody(code) { | ||
// EOF, whitespace, pipe | ||
/** | ||
* In table body row data. | ||
* | ||
* ```markdown | ||
* | | a | | ||
* | | - | | ||
* > | | b | | ||
* ^ | ||
* ``` | ||
* | ||
* @type {State} | ||
*/ | ||
function bodyRowData(code) { | ||
if ( | ||
@@ -505,138 +600,351 @@ code === codes.eof || | ||
) { | ||
effects.exit('temporaryTableCellContent') | ||
return cellBreakBody(code) | ||
effects.exit(types.data) | ||
return bodyRowBreak(code) | ||
} | ||
effects.consume(code) | ||
return code === codes.backslash | ||
? inCellContentEscapeBody | ||
: inCellContentBody | ||
return code === codes.backslash ? bodyRowEscape : bodyRowData | ||
} | ||
/** @type {State} */ | ||
function inCellContentEscapeBody(code) { | ||
/** | ||
* In table body row escape. | ||
* | ||
* ```markdown | ||
* | | a | | ||
* | | ---- | | ||
* > | | b\-c | | ||
* ^ | ||
* ``` | ||
* | ||
* @type {State} | ||
*/ | ||
function bodyRowEscape(code) { | ||
if (code === codes.backslash || code === codes.verticalBar) { | ||
effects.consume(code) | ||
return inCellContentBody | ||
return bodyRowData | ||
} | ||
// Anything else. | ||
return inCellContentBody(code) | ||
return bodyRowData(code) | ||
} | ||
} | ||
/** @type {State} */ | ||
function atRowEndBody(code) { | ||
effects.exit('tableRow') | ||
/** @type {Resolver} */ | ||
// eslint-disable-next-line complexity | ||
function resolveTable(events, context) { | ||
let index = -1 | ||
let inFirstCellAwaitingPipe = true | ||
/** @type {RowKind} */ | ||
let rowKind = 0 | ||
/** @type {Range} */ | ||
let lastCell = [0, 0, 0, 0] | ||
/** @type {Range} */ | ||
let cell = [0, 0, 0, 0] | ||
let afterHeadAwaitingFirstBodyRow = false | ||
let lastTableEnd = 0 | ||
/** @type {Token | undefined} */ | ||
let currentTable | ||
/** @type {Token | undefined} */ | ||
let currentBody | ||
/** @type {Token | undefined} */ | ||
let currentCell | ||
if (code === codes.eof) { | ||
return tableBodyClose(code) | ||
} | ||
const map = new EditMap() | ||
return effects.check( | ||
nextPrefixedOrBlank, | ||
tableBodyClose, | ||
effects.attempt( | ||
{tokenize: tokenizeRowEnd, partial: true}, | ||
factorySpace( | ||
effects, | ||
rowStartBody, | ||
types.linePrefix, | ||
constants.tabSize | ||
), | ||
tableBodyClose | ||
) | ||
)(code) | ||
} | ||
while (++index < events.length) { | ||
const event = events[index] | ||
const token = event[1] | ||
/** @type {State} */ | ||
function tableBodyClose(code) { | ||
effects.exit('tableBody') | ||
return tableClose(code) | ||
} | ||
if (event[0] === 'enter') { | ||
// Start of head. | ||
if (token.type === 'tableHead') { | ||
afterHeadAwaitingFirstBodyRow = false | ||
/** @type {Tokenizer} */ | ||
function tokenizeRowEnd(effects, ok, nok) { | ||
return start | ||
// Inject previous (body end and) table end. | ||
if (lastTableEnd !== 0) { | ||
assert(currentTable, 'there should be a table opening') | ||
flushTableEnd(map, context, lastTableEnd, currentTable, currentBody) | ||
currentBody = undefined | ||
lastTableEnd = 0 | ||
} | ||
/** @type {State} */ | ||
function start(code) { | ||
assert(markdownLineEnding(code), 'expected eol') | ||
effects.enter(types.lineEnding) | ||
effects.consume(code) | ||
effects.exit(types.lineEnding) | ||
return factorySpace(effects, prefixed, types.linePrefix) | ||
} | ||
/** @type {State} */ | ||
function prefixed(code) { | ||
// Blank or interrupting line. | ||
if ( | ||
self.parser.lazy[self.now().line] || | ||
code === codes.eof || | ||
markdownLineEnding(code) | ||
// Inject table start. | ||
currentTable = { | ||
type: 'table', | ||
start: Object.assign({}, token.start), | ||
// Note: correct end is set later. | ||
end: Object.assign({}, token.end) | ||
} | ||
map.add(index, 0, [['enter', currentTable, context]]) | ||
} else if ( | ||
token.type === 'tableRow' || | ||
token.type === 'tableDelimiterRow' | ||
) { | ||
return nok(code) | ||
} | ||
inFirstCellAwaitingPipe = true | ||
currentCell = undefined | ||
lastCell = [0, 0, 0, 0] | ||
cell = [0, index + 1, 0, 0] | ||
const tail = self.events[self.events.length - 1] | ||
// Inject table body start. | ||
if (afterHeadAwaitingFirstBodyRow) { | ||
afterHeadAwaitingFirstBodyRow = false | ||
currentBody = { | ||
type: 'tableBody', | ||
start: Object.assign({}, token.start), | ||
// Note: correct end is set later. | ||
end: Object.assign({}, token.end) | ||
} | ||
map.add(index, 0, [['enter', currentBody, context]]) | ||
} | ||
// Indented code can interrupt delimiter and body rows. | ||
if ( | ||
!self.parser.constructs.disable.null.includes('codeIndented') && | ||
tail && | ||
tail[1].type === types.linePrefix && | ||
tail[2].sliceSerialize(tail[1], true).length >= constants.tabSize | ||
rowKind = token.type === 'tableDelimiterRow' ? 2 : currentBody ? 3 : 1 | ||
} | ||
// Cell data. | ||
else if ( | ||
rowKind && | ||
(token.type === types.data || | ||
token.type === 'tableDelimiterMarker' || | ||
token.type === 'tableDelimiterFiller') | ||
) { | ||
return nok(code) | ||
} | ||
inFirstCellAwaitingPipe = false | ||
self._gfmTableDynamicInterruptHack = true | ||
// First value in cell. | ||
if (cell[2] === 0) { | ||
if (lastCell[1] !== 0) { | ||
cell[0] = cell[1] | ||
currentCell = flushCell( | ||
map, | ||
context, | ||
lastCell, | ||
rowKind, | ||
undefined, | ||
currentCell | ||
) | ||
lastCell = [0, 0, 0, 0] | ||
} | ||
return effects.check( | ||
self.parser.constructs.flow, | ||
function (code) { | ||
self._gfmTableDynamicInterruptHack = false | ||
return nok(code) | ||
}, | ||
function (code) { | ||
self._gfmTableDynamicInterruptHack = false | ||
return ok(code) | ||
cell[2] = index | ||
} | ||
)(code) | ||
} else if (token.type === 'tableCellDivider') { | ||
if (inFirstCellAwaitingPipe) { | ||
inFirstCellAwaitingPipe = false | ||
} else { | ||
if (lastCell[1] !== 0) { | ||
cell[0] = cell[1] | ||
currentCell = flushCell( | ||
map, | ||
context, | ||
lastCell, | ||
rowKind, | ||
undefined, | ||
currentCell | ||
) | ||
} | ||
lastCell = cell | ||
cell = [lastCell[1], index, 0, 0] | ||
} | ||
} | ||
} | ||
// Exit events. | ||
else if (token.type === 'tableHead') { | ||
afterHeadAwaitingFirstBodyRow = true | ||
lastTableEnd = index | ||
} else if ( | ||
token.type === 'tableRow' || | ||
token.type === 'tableDelimiterRow' | ||
) { | ||
lastTableEnd = index | ||
if (lastCell[1] !== 0) { | ||
cell[0] = cell[1] | ||
currentCell = flushCell( | ||
map, | ||
context, | ||
lastCell, | ||
rowKind, | ||
index, | ||
currentCell | ||
) | ||
} else if (cell[1] !== 0) { | ||
currentCell = flushCell(map, context, cell, rowKind, index, currentCell) | ||
} | ||
rowKind = 0 | ||
} else if ( | ||
rowKind && | ||
(token.type === types.data || | ||
token.type === 'tableDelimiterMarker' || | ||
token.type === 'tableDelimiterFiller') | ||
) { | ||
cell[3] = index | ||
} | ||
} | ||
if (lastTableEnd !== 0) { | ||
assert(currentTable, 'expected table opening') | ||
flushTableEnd(map, context, lastTableEnd, currentTable, currentBody) | ||
} | ||
map.consume(context.events) | ||
// To do: move this into `html`, when events are exposed there. | ||
// That’s what `markdown-rs` does. | ||
// That needs updates to `mdast-util-gfm-table`. | ||
index = -1 | ||
while (++index < context.events.length) { | ||
const event = context.events[index] | ||
if (event[0] === 'enter' && event[1].type === 'table') { | ||
// @ts-expect-error: custom field. | ||
event[1]._align = gfmTableAlign(context.events, index) | ||
} | ||
} | ||
return events | ||
} | ||
/** @type {Tokenizer} */ | ||
function tokenizeNextPrefixedOrBlank(effects, ok, nok) { | ||
let size = 0 | ||
/// Generate a cell. | ||
/** | ||
* | ||
* @param {EditMap} map | ||
* @param {TokenizeContext} context | ||
* @param {Range} range | ||
* @param {RowKind} rowKind | ||
* @param {number | undefined} rowEnd | ||
* @param {Token | undefined} previousCell | ||
* @returns {Token | undefined} | ||
*/ | ||
// eslint-disable-next-line max-params | ||
function flushCell(map, context, range, rowKind, rowEnd, previousCell) { | ||
// `markdown-rs` uses: | ||
// rowKind === 2 ? 'tableDelimiterCell' : 'tableCell' | ||
const groupName = | ||
rowKind === 1 | ||
? 'tableHeader' | ||
: rowKind === 2 | ||
? 'tableDelimiter' | ||
: 'tableData' | ||
// `markdown-rs` uses: | ||
// rowKind === 2 ? 'tableDelimiterCellValue' : 'tableCellText' | ||
const valueName = 'tableContent' | ||
return start | ||
// Insert an exit for the previous cell, if there is one. | ||
// | ||
// ```markdown | ||
// > | | aa | bb | cc | | ||
// ^-- exit | ||
// ^^^^-- this cell | ||
// ``` | ||
if (range[0] !== 0) { | ||
assert(previousCell, 'expected previous cell enter') | ||
previousCell.end = Object.assign({}, getPoint(context.events, range[0])) | ||
map.add(range[0], 0, [['exit', previousCell, context]]) | ||
} | ||
/** @type {State} */ | ||
function start(code) { | ||
// This is a check, so we don’t care about tokens, but we open a bogus one | ||
// so we’re valid. | ||
effects.enter('check') | ||
// EOL. | ||
effects.consume(code) | ||
return whitespace | ||
// Insert enter of this cell. | ||
// | ||
// ```markdown | ||
// > | | aa | bb | cc | | ||
// ^-- enter | ||
// ^^^^-- this cell | ||
// ``` | ||
const now = getPoint(context.events, range[1]) | ||
previousCell = { | ||
type: groupName, | ||
start: Object.assign({}, now), | ||
// Note: correct end is set later. | ||
end: Object.assign({}, now) | ||
} | ||
map.add(range[1], 0, [['enter', previousCell, context]]) | ||
/** @type {State} */ | ||
function whitespace(code) { | ||
if (code === codes.virtualSpace || code === codes.space) { | ||
effects.consume(code) | ||
size++ | ||
return size === constants.tabSize ? ok : whitespace | ||
// Insert text start at first data start and end at last data end, and | ||
// remove events between. | ||
// | ||
// ```markdown | ||
// > | | aa | bb | cc | | ||
// ^-- enter | ||
// ^-- exit | ||
// ^^^^-- this cell | ||
// ``` | ||
if (range[2] !== 0) { | ||
const relatedStart = getPoint(context.events, range[2]) | ||
const relatedEnd = getPoint(context.events, range[3]) | ||
const valueToken = { | ||
type: valueName, | ||
start: Object.assign({}, relatedStart), | ||
end: Object.assign({}, relatedEnd) | ||
} | ||
map.add(range[2], 0, [['enter', valueToken, context]]) | ||
assert(range[3] !== 0) | ||
// EOF or whitespace | ||
if (code === codes.eof || markdownLineEndingOrSpace(code)) { | ||
return ok(code) | ||
if (rowKind !== 2) { | ||
// Fix positional info on remaining events | ||
const start = context.events[range[2]] | ||
const end = context.events[range[3]] | ||
start[1].end = Object.assign({}, end[1].end) | ||
start[1].type = types.chunkText | ||
// @ts-expect-error It’s fine. | ||
start[1].contentType = constants.contentTypeText | ||
// Remove if needed. | ||
if (range[3] > range[2] + 1) { | ||
const a = range[2] + 1 | ||
const b = range[3] - range[2] - 1 | ||
map.add(a, b, []) | ||
} | ||
} | ||
// Anything else. | ||
return nok(code) | ||
map.add(range[3] + 1, 0, [['exit', valueToken, context]]) | ||
} | ||
// Insert an exit for the last cell, if at the row end. | ||
// | ||
// ```markdown | ||
// > | | aa | bb | cc | | ||
// ^-- exit | ||
// ^^^^^^-- this cell (the last one contains two “between” parts) | ||
// ``` | ||
if (rowEnd !== undefined) { | ||
previousCell.end = Object.assign({}, getPoint(context.events, rowEnd)) | ||
map.add(rowEnd, 0, [['exit', previousCell, context]]) | ||
previousCell = undefined | ||
} | ||
return previousCell | ||
} | ||
/** | ||
* Generate table end (and table body end). | ||
* | ||
* @param {EditMap} map | ||
* @param {TokenizeContext} context | ||
* @param {number} index | ||
* @param {Token} table | ||
* @param {Token | undefined} tableBody | ||
*/ | ||
// eslint-disable-next-line max-params | ||
function flushTableEnd(map, context, index, table, tableBody) { | ||
/** @type {Array<Event>} */ | ||
const exits = [] | ||
const related = getPoint(context.events, index) | ||
if (tableBody) { | ||
tableBody.end = Object.assign({}, related) | ||
exits.push(['exit', tableBody, context]) | ||
} | ||
table.end = Object.assign({}, related) | ||
exits.push(['exit', table, context]) | ||
map.add(index + 1, 0, exits) | ||
} | ||
/** | ||
* @param {Array<Event>} events | ||
* @param {number} index | ||
* @returns {readonly Point} | ||
*/ | ||
function getPoint(events, index) { | ||
const event = events[index] | ||
const side = event[0] === 'enter' ? 'start' : 'end' | ||
return event[1][side] | ||
} |
@@ -1,4 +0,9 @@ | ||
/** @type {HtmlExtension} */ | ||
/** | ||
* Extension for `micromark` that can be passed in `htmlExtensions` to support | ||
* GFM tables when serializing to HTML. | ||
* | ||
* @type {HtmlExtension} | ||
*/ | ||
export const gfmTableHtml: HtmlExtension | ||
export type HtmlExtension = import('micromark-util-types').HtmlExtension | ||
export type Align = import('./syntax.js').Align | ||
export type Align = import('./infer.js').Align |
/** | ||
* @typedef {import('micromark-util-types').HtmlExtension} HtmlExtension | ||
* @typedef {import('./syntax.js').Align} Align | ||
*/ | ||
/** | ||
* @typedef {import('./infer.js').Align} Align | ||
*/ | ||
const alignment = { | ||
@@ -11,4 +15,12 @@ none: '', | ||
} | ||
/** @type {HtmlExtension} */ | ||
// To do: next major: expose functions. | ||
// To do: next major: use `infer` here, when all events are exposed. | ||
/** | ||
* Extension for `micromark` that can be passed in `htmlExtensions` to support | ||
* GFM tables when serializing to HTML. | ||
* | ||
* @type {HtmlExtension} | ||
*/ | ||
export const gfmTableHtml = { | ||
@@ -24,18 +36,9 @@ enter: { | ||
}, | ||
tableBody() { | ||
// Clear slurping line ending from the delimiter row. | ||
this.setData('slurpOneLineEnding') | ||
this.tag('<tbody>') | ||
}, | ||
tableData() { | ||
const tableAlign = | ||
/** @type {Array<Align>} */ | ||
this.getData('tableAlign') | ||
const tableColumn = | ||
/** @type {number} */ | ||
this.getData('tableColumn') | ||
const tableAlign = /** @type {Array<Align>} */ this.getData('tableAlign') | ||
const tableColumn = /** @type {number} */ this.getData('tableColumn') | ||
const align = alignment[tableAlign[tableColumn]] | ||
if (align === undefined) { | ||
@@ -49,3 +52,2 @@ // Capture results to ignore them. | ||
}, | ||
tableHead() { | ||
@@ -55,10 +57,5 @@ this.lineEndingIfNeeded() | ||
}, | ||
tableHeader() { | ||
const tableAlign = | ||
/** @type {Array<Align>} */ | ||
this.getData('tableAlign') | ||
const tableColumn = | ||
/** @type {number} */ | ||
this.getData('tableColumn') | ||
const tableAlign = /** @type {Array<Align>} */ this.getData('tableAlign') | ||
const tableColumn = /** @type {number} */ this.getData('tableColumn') | ||
const align = alignment[tableAlign[tableColumn]] | ||
@@ -68,3 +65,2 @@ this.lineEndingIfNeeded() | ||
}, | ||
tableRow() { | ||
@@ -81,14 +77,12 @@ this.setData('tableColumn', 0) | ||
let value = this.sliceSerialize(token) | ||
if (this.getData('tableAlign')) { | ||
value = value.replace(/\\([\\|])/g, replace) | ||
} | ||
this.raw(this.encode(value)) | ||
}, | ||
table() { | ||
this.setData('tableAlign') // If there was no table body, make sure the slurping from the delimiter row | ||
// is cleared. | ||
this.setData('tableAlign') | ||
// Note: we don’t set `slurpAllLineEndings` anymore, in delimiter rows, | ||
// but we do need to reset it to match a funky newline GH generates for | ||
// list items combined with tables. | ||
this.setData('slurpAllLineEndings') | ||
@@ -98,3 +92,2 @@ this.lineEndingIfNeeded() | ||
}, | ||
tableBody() { | ||
@@ -104,11 +97,5 @@ this.lineEndingIfNeeded() | ||
}, | ||
tableData() { | ||
const tableAlign = | ||
/** @type {Array<Align>} */ | ||
this.getData('tableAlign') | ||
const tableColumn = | ||
/** @type {number} */ | ||
this.getData('tableColumn') | ||
const tableAlign = /** @type {Array<Align>} */ this.getData('tableAlign') | ||
const tableColumn = /** @type {number} */ this.getData('tableColumn') | ||
if (tableColumn in tableAlign) { | ||
@@ -122,25 +109,14 @@ this.tag('</td>') | ||
}, | ||
tableHead() { | ||
this.lineEndingIfNeeded() | ||
this.tag('</thead>') | ||
this.setData('slurpOneLineEnding', true) // Slurp the line ending from the delimiter row. | ||
}, | ||
tableHeader() { | ||
const tableColumn = | ||
/** @type {number} */ | ||
this.getData('tableColumn') | ||
const tableColumn = /** @type {number} */ this.getData('tableColumn') | ||
this.tag('</th>') | ||
this.setData('tableColumn', tableColumn + 1) | ||
}, | ||
tableRow() { | ||
const tableAlign = | ||
/** @type {Array<Align>} */ | ||
this.getData('tableAlign') | ||
let tableColumn = | ||
/** @type {number} */ | ||
this.getData('tableColumn') | ||
const tableAlign = /** @type {Array<Align>} */ this.getData('tableAlign') | ||
let tableColumn = /** @type {number} */ this.getData('tableColumn') | ||
while (tableColumn < tableAlign.length) { | ||
@@ -151,3 +127,2 @@ this.lineEndingIfNeeded() | ||
} | ||
this.setData('tableColumn', tableColumn) | ||
@@ -159,2 +134,3 @@ this.lineEndingIfNeeded() | ||
} | ||
/** | ||
@@ -165,3 +141,2 @@ * @param {string} $0 | ||
*/ | ||
function replace($0, $1) { | ||
@@ -168,0 +143,0 @@ // Pipes work, backslashes don’t (but can’t escape pipes). |
@@ -1,8 +0,23 @@ | ||
/** @type {Extension} */ | ||
/** | ||
* Extension for `micromark` that can be passed in `extensions` to enable GFM | ||
* table syntax. | ||
* | ||
* @type {Extension} | ||
*/ | ||
export const gfmTable: Extension | ||
export type Event = import('micromark-util-types').Event | ||
export type Extension = import('micromark-util-types').Extension | ||
export type Point = import('micromark-util-types').Point | ||
export type Resolver = import('micromark-util-types').Resolver | ||
export type Tokenizer = import('micromark-util-types').Tokenizer | ||
export type State = import('micromark-util-types').State | ||
export type Token = import('micromark-util-types').Token | ||
export type Align = 'left' | 'center' | 'right' | 'none' | ||
export type TokenizeContext = import('micromark-util-types').TokenizeContext | ||
export type Tokenizer = import('micromark-util-types').Tokenizer | ||
/** | ||
* Cell info. | ||
*/ | ||
export type Range = [number, number, number, number] | ||
/** | ||
* Where we are: `1` for head row, `2` for delimiter row, `3` for body row. | ||
*/ | ||
export type RowKind = 0 | 1 | 2 | 3 |
1204
lib/syntax.js
/** | ||
* @typedef {import('micromark-util-types').Event} Event | ||
* @typedef {import('micromark-util-types').Extension} Extension | ||
* @typedef {import('micromark-util-types').Point} Point | ||
* @typedef {import('micromark-util-types').Resolver} Resolver | ||
* @typedef {import('micromark-util-types').Tokenizer} Tokenizer | ||
* @typedef {import('micromark-util-types').State} State | ||
* @typedef {import('micromark-util-types').Token} Token | ||
* @typedef {import('micromark-util-types').TokenizeContext} TokenizeContext | ||
* @typedef {import('micromark-util-types').Tokenizer} Tokenizer | ||
*/ | ||
/** | ||
* @typedef {'left'|'center'|'right'|'none'} Align | ||
* @typedef {[number, number, number, number]} Range | ||
* Cell info. | ||
* | ||
* @typedef {0 | 1 | 2 | 3} RowKind | ||
* Where we are: `1` for head row, `2` for delimiter row, `3` for body row. | ||
*/ | ||
import {factorySpace} from 'micromark-factory-space' | ||
@@ -18,4 +26,13 @@ import { | ||
} from 'micromark-util-character' | ||
import {EditMap} from './edit-map.js' | ||
import {gfmTableAlign} from './infer.js' | ||
/** @type {Extension} */ | ||
// To do: next major: expose functions. | ||
/** | ||
* Extension for `micromark` that can be passed in `extensions` to enable GFM | ||
* table syntax. | ||
* | ||
* @type {Extension} | ||
*/ | ||
export const gfmTable = { | ||
@@ -25,583 +42,844 @@ flow: { | ||
tokenize: tokenizeTable, | ||
resolve: resolveTable | ||
resolveAll: resolveTable | ||
} | ||
} | ||
} | ||
const nextPrefixedOrBlank = { | ||
tokenize: tokenizeNextPrefixedOrBlank, | ||
partial: true | ||
} | ||
/** @type {Resolver} */ | ||
function resolveTable(events, context) { | ||
let index = -1 | ||
/** @type {boolean|undefined} */ | ||
/** | ||
* @this {TokenizeContext} | ||
* @type {Tokenizer} | ||
*/ | ||
function tokenizeTable(effects, ok, nok) { | ||
const self = this | ||
let size = 0 | ||
let sizeB = 0 | ||
/** @type {boolean | undefined} */ | ||
let seen | ||
return start | ||
let inHead | ||
/** @type {boolean|undefined} */ | ||
let inDelimiterRow | ||
/** @type {boolean|undefined} */ | ||
let inRow | ||
/** @type {number|undefined} */ | ||
let contentStart | ||
/** @type {number|undefined} */ | ||
let contentEnd | ||
/** @type {number|undefined} */ | ||
let cellStart | ||
/** @type {boolean|undefined} */ | ||
let seenCellInRow | ||
while (++index < events.length) { | ||
const token = events[index][1] | ||
if (inRow) { | ||
if (token.type === 'temporaryTableCellContent') { | ||
contentStart = contentStart || index | ||
contentEnd = index | ||
} | ||
/** | ||
* Start of a GFM table. | ||
* | ||
* If there is a valid table row or table head before, then we try to parse | ||
* another row. | ||
* Otherwise, we try to parse a head. | ||
* | ||
* ```markdown | ||
* > | | a | | ||
* ^ | ||
* | | - | | ||
* > | | b | | ||
* ^ | ||
* ``` | ||
* @type {State} | ||
*/ | ||
function start(code) { | ||
let index = self.events.length - 1 | ||
while (index > -1) { | ||
const type = self.events[index][1].type | ||
if ( | ||
// Combine separate content parts into one. | ||
(token.type === 'tableCellDivider' || token.type === 'tableRow') && | ||
contentEnd | ||
) { | ||
const content = { | ||
type: 'tableContent', | ||
start: events[contentStart][1].start, | ||
end: events[contentEnd][1].end | ||
} | ||
/** @type {Token} */ | ||
const text = { | ||
type: 'chunkText', | ||
start: content.start, | ||
end: content.end, | ||
// @ts-expect-error It’s fine. | ||
contentType: 'text' | ||
} | ||
events.splice( | ||
contentStart, | ||
contentEnd - contentStart + 1, | ||
['enter', content, context], | ||
['enter', text, context], | ||
['exit', text, context], | ||
['exit', content, context] | ||
) | ||
index -= contentEnd - contentStart - 3 | ||
contentStart = undefined | ||
contentEnd = undefined | ||
} | ||
type === 'lineEnding' || | ||
// Note: markdown-rs uses `whitespace` instead of `linePrefix` | ||
type === 'linePrefix' | ||
) | ||
index-- | ||
else break | ||
} | ||
const tail = index > -1 ? self.events[index][1].type : null | ||
const next = | ||
tail === 'tableHead' || tail === 'tableRow' ? bodyRowStart : headRowBefore | ||
if ( | ||
events[index][0] === 'exit' && | ||
cellStart !== undefined && | ||
cellStart + (seenCellInRow ? 0 : 1) < index && | ||
(token.type === 'tableCellDivider' || | ||
(token.type === 'tableRow' && | ||
(cellStart + 3 < index || | ||
events[cellStart][1].type !== 'whitespace'))) | ||
) { | ||
const cell = { | ||
type: inDelimiterRow | ||
? 'tableDelimiter' | ||
: inHead | ||
? 'tableHeader' | ||
: 'tableData', | ||
start: events[cellStart][1].start, | ||
end: events[index][1].end | ||
} | ||
events.splice(index + (token.type === 'tableCellDivider' ? 1 : 0), 0, [ | ||
'exit', | ||
cell, | ||
context | ||
]) | ||
events.splice(cellStart, 0, ['enter', cell, context]) | ||
index += 2 | ||
cellStart = index + 1 | ||
seenCellInRow = true | ||
// Don’t allow lazy body rows. | ||
if (next === bodyRowStart && self.parser.lazy[self.now().line]) { | ||
return nok(code) | ||
} | ||
if (token.type === 'tableRow') { | ||
inRow = events[index][0] === 'enter' | ||
if (inRow) { | ||
cellStart = index + 1 | ||
seenCellInRow = false | ||
} | ||
} | ||
if (token.type === 'tableDelimiterRow') { | ||
inDelimiterRow = events[index][0] === 'enter' | ||
if (inDelimiterRow) { | ||
cellStart = index + 1 | ||
seenCellInRow = false | ||
} | ||
} | ||
if (token.type === 'tableHead') { | ||
inHead = events[index][0] === 'enter' | ||
} | ||
return next(code) | ||
} | ||
return events | ||
} | ||
/** @type {Tokenizer} */ | ||
function tokenizeTable(effects, ok, nok) { | ||
const self = this | ||
/** @type {Array<Align>} */ | ||
const align = [] | ||
let tableHeaderCount = 0 | ||
/** @type {boolean|undefined} */ | ||
let seenDelimiter | ||
/** @type {boolean|undefined} */ | ||
let hasDash | ||
return start | ||
/** @type {State} */ | ||
function start(code) { | ||
// @ts-expect-error Custom. | ||
effects.enter('table')._align = align | ||
/** | ||
* Before table head row. | ||
* | ||
* ```markdown | ||
* > | | a | | ||
* ^ | ||
* | | - | | ||
* | | b | | ||
* ``` | ||
* | ||
* @type {State} | ||
*/ | ||
function headRowBefore(code) { | ||
effects.enter('tableHead') | ||
effects.enter('tableRow') // If we start with a pipe, we open a cell marker. | ||
effects.enter('tableRow') | ||
return headRowStart(code) | ||
} | ||
/** | ||
* Before table head row, after whitespace. | ||
* | ||
* ```markdown | ||
* > | | a | | ||
* ^ | ||
* | | - | | ||
* | | b | | ||
* ``` | ||
* | ||
* @type {State} | ||
*/ | ||
function headRowStart(code) { | ||
if (code === 124) { | ||
return cellDividerHead(code) | ||
return headRowBreak(code) | ||
} | ||
tableHeaderCount++ | ||
effects.enter('temporaryTableCellContent') // Can’t be space or eols at the start of a construct, so we’re in a cell. | ||
// To do: micromark-js should let us parse our own whitespace in extensions, | ||
// like `markdown-rs`: | ||
// | ||
// ```js | ||
// // 4+ spaces. | ||
// if (markdownSpace(code)) { | ||
// return nok(code) | ||
// } | ||
// ``` | ||
return inCellContentHead(code) | ||
seen = true | ||
// Count the first character, that isn’t a pipe, double. | ||
sizeB += 1 | ||
return headRowBreak(code) | ||
} | ||
/** @type {State} */ | ||
function cellDividerHead(code) { | ||
effects.enter('tableCellDivider') | ||
effects.consume(code) | ||
effects.exit('tableCellDivider') | ||
seenDelimiter = true | ||
return cellBreakHead | ||
} | ||
/** @type {State} */ | ||
/** | ||
* At break in table head row. | ||
* | ||
* ```markdown | ||
* > | | a | | ||
* ^ | ||
* ^ | ||
* ^ | ||
* | | - | | ||
* | | b | | ||
* ``` | ||
* | ||
* @type {State} | ||
*/ | ||
function headRowBreak(code) { | ||
if (code === null) { | ||
// Note: in `markdown-rs`, we need to reset, in `micromark-js` we don‘t. | ||
return nok(code) | ||
} | ||
if (markdownLineEnding(code)) { | ||
// If anything other than one pipe (ignoring whitespace) was used, it’s fine. | ||
if (sizeB > 1) { | ||
sizeB = 0 | ||
// To do: check if this works. | ||
// Feel free to interrupt: | ||
self.interrupt = true | ||
effects.exit('tableRow') | ||
effects.enter('lineEnding') | ||
effects.consume(code) | ||
effects.exit('lineEnding') | ||
return headDelimiterStart | ||
} | ||
function cellBreakHead(code) { | ||
if (code === null || markdownLineEnding(code)) { | ||
return atRowEndHead(code) | ||
// Note: in `markdown-rs`, we need to reset, in `micromark-js` we don‘t. | ||
return nok(code) | ||
} | ||
if (markdownSpace(code)) { | ||
effects.enter('whitespace') | ||
effects.consume(code) | ||
return inWhitespaceHead | ||
// To do: check if this is fine. | ||
// effects.attempt(State::Next(StateName::GfmTableHeadRowBreak), State::Nok) | ||
// State::Retry(space_or_tab(tokenizer)) | ||
return factorySpace(effects, headRowBreak, 'whitespace')(code) | ||
} | ||
if (seenDelimiter) { | ||
seenDelimiter = undefined | ||
tableHeaderCount++ | ||
sizeB += 1 | ||
if (seen) { | ||
seen = false | ||
// Header cell count. | ||
size += 1 | ||
} | ||
if (code === 124) { | ||
return cellDividerHead(code) | ||
} // Anything else is cell content. | ||
effects.enter('temporaryTableCellContent') | ||
return inCellContentHead(code) | ||
} | ||
/** @type {State} */ | ||
function inWhitespaceHead(code) { | ||
if (markdownSpace(code)) { | ||
effects.enter('tableCellDivider') | ||
effects.consume(code) | ||
return inWhitespaceHead | ||
effects.exit('tableCellDivider') | ||
// Whether a delimiter was seen. | ||
seen = true | ||
return headRowBreak | ||
} | ||
effects.exit('whitespace') | ||
return cellBreakHead(code) | ||
// Anything else is cell data. | ||
effects.enter('data') | ||
return headRowData(code) | ||
} | ||
/** @type {State} */ | ||
function inCellContentHead(code) { | ||
// EOF, whitespace, pipe | ||
/** | ||
* In table head row data. | ||
* | ||
* ```markdown | ||
* > | | a | | ||
* ^ | ||
* | | - | | ||
* | | b | | ||
* ``` | ||
* | ||
* @type {State} | ||
*/ | ||
function headRowData(code) { | ||
if (code === null || code === 124 || markdownLineEndingOrSpace(code)) { | ||
effects.exit('temporaryTableCellContent') | ||
return cellBreakHead(code) | ||
effects.exit('data') | ||
return headRowBreak(code) | ||
} | ||
effects.consume(code) | ||
return code === 92 ? inCellContentEscapeHead : inCellContentHead | ||
return code === 92 ? headRowEscape : headRowData | ||
} | ||
/** @type {State} */ | ||
function inCellContentEscapeHead(code) { | ||
/** | ||
* In table head row escape. | ||
* | ||
* ```markdown | ||
* > | | a\-b | | ||
* ^ | ||
* | | ---- | | ||
* | | c | | ||
* ``` | ||
* | ||
* @type {State} | ||
*/ | ||
function headRowEscape(code) { | ||
if (code === 92 || code === 124) { | ||
effects.consume(code) | ||
return inCellContentHead | ||
} // Anything else. | ||
return inCellContentHead(code) | ||
return headRowData | ||
} | ||
return headRowData(code) | ||
} | ||
/** @type {State} */ | ||
function atRowEndHead(code) { | ||
if (code === null) { | ||
/** | ||
* Before delimiter row. | ||
* | ||
* ```markdown | ||
* | | a | | ||
* > | | - | | ||
* ^ | ||
* | | b | | ||
* ``` | ||
* | ||
* @type {State} | ||
*/ | ||
function headDelimiterStart(code) { | ||
// Reset `interrupt`. | ||
self.interrupt = false | ||
// Note: in `markdown-rs`, we need to handle piercing here too. | ||
if (self.parser.lazy[self.now().line]) { | ||
return nok(code) | ||
} | ||
effects.exit('tableRow') | ||
effects.exit('tableHead') | ||
const originalInterrupt = self.interrupt | ||
self.interrupt = true | ||
return effects.attempt( | ||
{ | ||
tokenize: tokenizeRowEnd, | ||
partial: true | ||
}, | ||
function (code) { | ||
self.interrupt = originalInterrupt | ||
effects.enter('tableDelimiterRow') | ||
return atDelimiterRowBreak(code) | ||
}, | ||
function (code) { | ||
self.interrupt = originalInterrupt | ||
return nok(code) | ||
} | ||
)(code) | ||
} | ||
/** @type {State} */ | ||
function atDelimiterRowBreak(code) { | ||
if (code === null || markdownLineEnding(code)) { | ||
return rowEndDelimiter(code) | ||
} | ||
effects.enter('tableDelimiterRow') | ||
// Track if we’ve seen a `:` or `|`. | ||
seen = false | ||
if (markdownSpace(code)) { | ||
effects.enter('whitespace') | ||
effects.consume(code) | ||
return inWhitespaceDelimiter | ||
return factorySpace( | ||
effects, | ||
headDelimiterBefore, | ||
'linePrefix', | ||
self.parser.constructs.disable.null.includes('codeIndented') | ||
? undefined | ||
: 4 | ||
)(code) | ||
} | ||
return headDelimiterBefore(code) | ||
} | ||
if (code === 45) { | ||
effects.enter('tableDelimiterFiller') | ||
effects.consume(code) | ||
hasDash = true | ||
align.push('none') | ||
return inFillerDelimiter | ||
/** | ||
* Before delimiter row, after optional whitespace. | ||
* | ||
* Reused when a `|` is found later, to parse another cell. | ||
* | ||
* ```markdown | ||
* | | a | | ||
* > | | - | | ||
* ^ | ||
* | | b | | ||
* ``` | ||
* | ||
* @type {State} | ||
*/ | ||
function headDelimiterBefore(code) { | ||
if (code === 45 || code === 58) { | ||
return headDelimiterValueBefore(code) | ||
} | ||
if (code === 58) { | ||
effects.enter('tableDelimiterAlignment') | ||
effects.consume(code) | ||
effects.exit('tableDelimiterAlignment') | ||
align.push('left') | ||
return afterLeftAlignment | ||
} // If we start with a pipe, we open a cell marker. | ||
if (code === 124) { | ||
seen = true | ||
// If we start with a pipe, we open a cell marker. | ||
effects.enter('tableCellDivider') | ||
effects.consume(code) | ||
effects.exit('tableCellDivider') | ||
return atDelimiterRowBreak | ||
return headDelimiterCellBefore | ||
} | ||
return nok(code) | ||
// More whitespace / empty row not allowed at start. | ||
return headDelimiterNok(code) | ||
} | ||
/** @type {State} */ | ||
function inWhitespaceDelimiter(code) { | ||
/** | ||
* After `|`, before delimiter cell. | ||
* | ||
* ```markdown | ||
* | | a | | ||
* > | | - | | ||
* ^ | ||
* ``` | ||
* | ||
* @type {State} | ||
*/ | ||
function headDelimiterCellBefore(code) { | ||
if (markdownSpace(code)) { | ||
effects.consume(code) | ||
return inWhitespaceDelimiter | ||
return factorySpace(effects, headDelimiterValueBefore, 'whitespace')(code) | ||
} | ||
effects.exit('whitespace') | ||
return atDelimiterRowBreak(code) | ||
return headDelimiterValueBefore(code) | ||
} | ||
/** @type {State} */ | ||
function inFillerDelimiter(code) { | ||
if (code === 45) { | ||
/** | ||
* Before delimiter cell value. | ||
* | ||
* ```markdown | ||
* | | a | | ||
* > | | - | | ||
* ^ | ||
* ``` | ||
* | ||
* @type {State} | ||
*/ | ||
function headDelimiterValueBefore(code) { | ||
// Align: left. | ||
if (code === 58) { | ||
sizeB += 1 | ||
seen = true | ||
effects.enter('tableDelimiterMarker') | ||
effects.consume(code) | ||
return inFillerDelimiter | ||
effects.exit('tableDelimiterMarker') | ||
return headDelimiterLeftAlignmentAfter | ||
} | ||
effects.exit('tableDelimiterFiller') | ||
if (code === 58) { | ||
effects.enter('tableDelimiterAlignment') | ||
effects.consume(code) | ||
effects.exit('tableDelimiterAlignment') | ||
align[align.length - 1] = | ||
align[align.length - 1] === 'left' ? 'center' : 'right' | ||
return afterRightAlignment | ||
// Align: none. | ||
if (code === 45) { | ||
sizeB += 1 | ||
// To do: seems weird that this *isn’t* left aligned, but that state is used? | ||
return headDelimiterLeftAlignmentAfter(code) | ||
} | ||
return atDelimiterRowBreak(code) | ||
if (code === null || markdownLineEnding(code)) { | ||
return headDelimiterCellAfter(code) | ||
} | ||
return headDelimiterNok(code) | ||
} | ||
/** @type {State} */ | ||
function afterLeftAlignment(code) { | ||
/** | ||
* After delimiter cell left alignment marker. | ||
* | ||
* ```markdown | ||
* | | a | | ||
* > | | :- | | ||
* ^ | ||
* ``` | ||
* | ||
* @type {State} | ||
*/ | ||
function headDelimiterLeftAlignmentAfter(code) { | ||
if (code === 45) { | ||
effects.enter('tableDelimiterFiller') | ||
effects.consume(code) | ||
hasDash = true | ||
return inFillerDelimiter | ||
} // Anything else is not ok. | ||
return headDelimiterFiller(code) | ||
} | ||
return nok(code) | ||
// Anything else is not ok after the left-align colon. | ||
return headDelimiterNok(code) | ||
} | ||
/** @type {State} */ | ||
function afterRightAlignment(code) { | ||
if (code === null || markdownLineEnding(code)) { | ||
return rowEndDelimiter(code) | ||
/** | ||
* In delimiter cell filler. | ||
* | ||
* ```markdown | ||
* | | a | | ||
* > | | - | | ||
* ^ | ||
* ``` | ||
* | ||
* @type {State} | ||
*/ | ||
function headDelimiterFiller(code) { | ||
if (code === 45) { | ||
effects.consume(code) | ||
return headDelimiterFiller | ||
} | ||
if (markdownSpace(code)) { | ||
effects.enter('whitespace') | ||
// Align is `center` if it was `left`, `right` otherwise. | ||
if (code === 58) { | ||
seen = true | ||
effects.exit('tableDelimiterFiller') | ||
effects.enter('tableDelimiterMarker') | ||
effects.consume(code) | ||
return inWhitespaceDelimiter | ||
} // `|` | ||
effects.exit('tableDelimiterMarker') | ||
return headDelimiterRightAlignmentAfter | ||
} | ||
effects.exit('tableDelimiterFiller') | ||
return headDelimiterRightAlignmentAfter(code) | ||
} | ||
if (code === 124) { | ||
effects.enter('tableCellDivider') | ||
effects.consume(code) | ||
effects.exit('tableCellDivider') | ||
return atDelimiterRowBreak | ||
/** | ||
* After delimiter cell right alignment marker. | ||
* | ||
* ```markdown | ||
* | | a | | ||
* > | | -: | | ||
* ^ | ||
* ``` | ||
* | ||
* @type {State} | ||
*/ | ||
function headDelimiterRightAlignmentAfter(code) { | ||
if (markdownSpace(code)) { | ||
return factorySpace(effects, headDelimiterCellAfter, 'whitespace')(code) | ||
} | ||
return nok(code) | ||
return headDelimiterCellAfter(code) | ||
} | ||
/** @type {State} */ | ||
function rowEndDelimiter(code) { | ||
effects.exit('tableDelimiterRow') // Exit if there was no dash at all, or if the header cell count is not the | ||
// delimiter cell count. | ||
if (!hasDash || tableHeaderCount !== align.length) { | ||
return nok(code) | ||
/** | ||
* After delimiter cell. | ||
* | ||
* ```markdown | ||
* | | a | | ||
* > | | -: | | ||
* ^ | ||
* ``` | ||
* | ||
* @type {State} | ||
*/ | ||
function headDelimiterCellAfter(code) { | ||
if (code === 124) { | ||
return headDelimiterBefore(code) | ||
} | ||
if (code === null || markdownLineEnding(code)) { | ||
// Exit when: | ||
// * there was no `:` or `|` at all (it’s a thematic break or setext | ||
// underline instead) | ||
// * the header cell count is not the delimiter cell count | ||
if (!seen || size !== sizeB) { | ||
return headDelimiterNok(code) | ||
} | ||
if (code === null) { | ||
return tableClose(code) | ||
// Note: in markdown-rs`, a reset is needed here. | ||
effects.exit('tableDelimiterRow') | ||
effects.exit('tableHead') | ||
// To do: in `markdown-rs`, resolvers need to be registered manually. | ||
// effects.register_resolver(ResolveName::GfmTable) | ||
return ok(code) | ||
} | ||
return effects.check( | ||
nextPrefixedOrBlank, | ||
tableClose, | ||
effects.attempt( | ||
{ | ||
tokenize: tokenizeRowEnd, | ||
partial: true | ||
}, | ||
factorySpace(effects, bodyStart, 'linePrefix', 4), | ||
tableClose | ||
) | ||
)(code) | ||
return headDelimiterNok(code) | ||
} | ||
/** @type {State} */ | ||
function tableClose(code) { | ||
effects.exit('table') | ||
return ok(code) | ||
/** | ||
* In delimiter row, at a disallowed byte. | ||
* | ||
* ```markdown | ||
* | | a | | ||
* > | | x | | ||
* ^ | ||
* ``` | ||
* | ||
* @type {State} | ||
*/ | ||
function headDelimiterNok(code) { | ||
// Note: in `markdown-rs`, we need to reset, in `micromark-js` we don‘t. | ||
return nok(code) | ||
} | ||
/** @type {State} */ | ||
function bodyStart(code) { | ||
effects.enter('tableBody') | ||
return rowStartBody(code) | ||
/** | ||
* Before table body row. | ||
* | ||
* ```markdown | ||
* | | a | | ||
* | | - | | ||
* > | | b | | ||
* ^ | ||
* ``` | ||
* | ||
* @type {State} | ||
*/ | ||
function bodyRowStart(code) { | ||
// Note: in `markdown-rs` we need to manually take care of a prefix, | ||
// but in `micromark-js` that is done for us, so if we’re here, we’re | ||
// never at whitespace. | ||
effects.enter('tableRow') | ||
return bodyRowBreak(code) | ||
} | ||
/** @type {State} */ | ||
function rowStartBody(code) { | ||
effects.enter('tableRow') // If we start with a pipe, we open a cell marker. | ||
/** | ||
* At break in table body row. | ||
* | ||
* ```markdown | ||
* | | a | | ||
* | | - | | ||
* > | | b | | ||
* ^ | ||
* ^ | ||
* ^ | ||
* ``` | ||
* | ||
* @type {State} | ||
*/ | ||
function bodyRowBreak(code) { | ||
if (code === 124) { | ||
return cellDividerBody(code) | ||
effects.enter('tableCellDivider') | ||
effects.consume(code) | ||
effects.exit('tableCellDivider') | ||
return bodyRowBreak | ||
} | ||
effects.enter('temporaryTableCellContent') // Can’t be space or eols at the start of a construct, so we’re in a cell. | ||
return inCellContentBody(code) | ||
} | ||
/** @type {State} */ | ||
function cellDividerBody(code) { | ||
effects.enter('tableCellDivider') | ||
effects.consume(code) | ||
effects.exit('tableCellDivider') | ||
return cellBreakBody | ||
} | ||
/** @type {State} */ | ||
function cellBreakBody(code) { | ||
if (code === null || markdownLineEnding(code)) { | ||
return atRowEndBody(code) | ||
effects.exit('tableRow') | ||
return ok(code) | ||
} | ||
if (markdownSpace(code)) { | ||
effects.enter('whitespace') | ||
effects.consume(code) | ||
return inWhitespaceBody | ||
} // `|` | ||
if (code === 124) { | ||
return cellDividerBody(code) | ||
} // Anything else is cell content. | ||
effects.enter('temporaryTableCellContent') | ||
return inCellContentBody(code) | ||
} | ||
/** @type {State} */ | ||
function inWhitespaceBody(code) { | ||
if (markdownSpace(code)) { | ||
effects.consume(code) | ||
return inWhitespaceBody | ||
return factorySpace(effects, bodyRowBreak, 'whitespace')(code) | ||
} | ||
effects.exit('whitespace') | ||
return cellBreakBody(code) | ||
// Anything else is cell content. | ||
effects.enter('data') | ||
return bodyRowData(code) | ||
} | ||
/** @type {State} */ | ||
function inCellContentBody(code) { | ||
// EOF, whitespace, pipe | ||
/** | ||
* In table body row data. | ||
* | ||
* ```markdown | ||
* | | a | | ||
* | | - | | ||
* > | | b | | ||
* ^ | ||
* ``` | ||
* | ||
* @type {State} | ||
*/ | ||
function bodyRowData(code) { | ||
if (code === null || code === 124 || markdownLineEndingOrSpace(code)) { | ||
effects.exit('temporaryTableCellContent') | ||
return cellBreakBody(code) | ||
effects.exit('data') | ||
return bodyRowBreak(code) | ||
} | ||
effects.consume(code) | ||
return code === 92 ? inCellContentEscapeBody : inCellContentBody | ||
return code === 92 ? bodyRowEscape : bodyRowData | ||
} | ||
/** @type {State} */ | ||
function inCellContentEscapeBody(code) { | ||
/** | ||
* In table body row escape. | ||
* | ||
* ```markdown | ||
* | | a | | ||
* | | ---- | | ||
* > | | b\-c | | ||
* ^ | ||
* ``` | ||
* | ||
* @type {State} | ||
*/ | ||
function bodyRowEscape(code) { | ||
if (code === 92 || code === 124) { | ||
effects.consume(code) | ||
return inCellContentBody | ||
} // Anything else. | ||
return inCellContentBody(code) | ||
} | ||
/** @type {State} */ | ||
function atRowEndBody(code) { | ||
effects.exit('tableRow') | ||
if (code === null) { | ||
return tableBodyClose(code) | ||
return bodyRowData | ||
} | ||
return effects.check( | ||
nextPrefixedOrBlank, | ||
tableBodyClose, | ||
effects.attempt( | ||
{ | ||
tokenize: tokenizeRowEnd, | ||
partial: true | ||
}, | ||
factorySpace(effects, rowStartBody, 'linePrefix', 4), | ||
tableBodyClose | ||
) | ||
)(code) | ||
return bodyRowData(code) | ||
} | ||
/** @type {State} */ | ||
} | ||
function tableBodyClose(code) { | ||
effects.exit('tableBody') | ||
return tableClose(code) | ||
} | ||
/** @type {Tokenizer} */ | ||
/** @type {Resolver} */ | ||
// eslint-disable-next-line complexity | ||
function resolveTable(events, context) { | ||
let index = -1 | ||
let inFirstCellAwaitingPipe = true | ||
/** @type {RowKind} */ | ||
let rowKind = 0 | ||
/** @type {Range} */ | ||
let lastCell = [0, 0, 0, 0] | ||
/** @type {Range} */ | ||
let cell = [0, 0, 0, 0] | ||
let afterHeadAwaitingFirstBodyRow = false | ||
let lastTableEnd = 0 | ||
/** @type {Token | undefined} */ | ||
let currentTable | ||
/** @type {Token | undefined} */ | ||
let currentBody | ||
/** @type {Token | undefined} */ | ||
let currentCell | ||
const map = new EditMap() | ||
while (++index < events.length) { | ||
const event = events[index] | ||
const token = event[1] | ||
if (event[0] === 'enter') { | ||
// Start of head. | ||
if (token.type === 'tableHead') { | ||
afterHeadAwaitingFirstBodyRow = false | ||
function tokenizeRowEnd(effects, ok, nok) { | ||
return start | ||
/** @type {State} */ | ||
// Inject previous (body end and) table end. | ||
if (lastTableEnd !== 0) { | ||
flushTableEnd(map, context, lastTableEnd, currentTable, currentBody) | ||
currentBody = undefined | ||
lastTableEnd = 0 | ||
} | ||
function start(code) { | ||
effects.enter('lineEnding') | ||
effects.consume(code) | ||
effects.exit('lineEnding') | ||
return factorySpace(effects, prefixed, 'linePrefix') | ||
} | ||
/** @type {State} */ | ||
// Inject table start. | ||
currentTable = { | ||
type: 'table', | ||
start: Object.assign({}, token.start), | ||
// Note: correct end is set later. | ||
end: Object.assign({}, token.end) | ||
} | ||
map.add(index, 0, [['enter', currentTable, context]]) | ||
} else if ( | ||
token.type === 'tableRow' || | ||
token.type === 'tableDelimiterRow' | ||
) { | ||
inFirstCellAwaitingPipe = true | ||
currentCell = undefined | ||
lastCell = [0, 0, 0, 0] | ||
cell = [0, index + 1, 0, 0] | ||
function prefixed(code) { | ||
// Blank or interrupting line. | ||
if ( | ||
self.parser.lazy[self.now().line] || | ||
code === null || | ||
markdownLineEnding(code) | ||
) { | ||
return nok(code) | ||
// Inject table body start. | ||
if (afterHeadAwaitingFirstBodyRow) { | ||
afterHeadAwaitingFirstBodyRow = false | ||
currentBody = { | ||
type: 'tableBody', | ||
start: Object.assign({}, token.start), | ||
// Note: correct end is set later. | ||
end: Object.assign({}, token.end) | ||
} | ||
map.add(index, 0, [['enter', currentBody, context]]) | ||
} | ||
rowKind = token.type === 'tableDelimiterRow' ? 2 : currentBody ? 3 : 1 | ||
} | ||
const tail = self.events[self.events.length - 1] // Indented code can interrupt delimiter and body rows. | ||
if ( | ||
!self.parser.constructs.disable.null.includes('codeIndented') && | ||
tail && | ||
tail[1].type === 'linePrefix' && | ||
tail[2].sliceSerialize(tail[1], true).length >= 4 | ||
// Cell data. | ||
else if ( | ||
rowKind && | ||
(token.type === 'data' || | ||
token.type === 'tableDelimiterMarker' || | ||
token.type === 'tableDelimiterFiller') | ||
) { | ||
return nok(code) | ||
} | ||
inFirstCellAwaitingPipe = false | ||
self._gfmTableDynamicInterruptHack = true | ||
return effects.check( | ||
self.parser.constructs.flow, | ||
function (code) { | ||
self._gfmTableDynamicInterruptHack = false | ||
return nok(code) | ||
}, | ||
function (code) { | ||
self._gfmTableDynamicInterruptHack = false | ||
return ok(code) | ||
// First value in cell. | ||
if (cell[2] === 0) { | ||
if (lastCell[1] !== 0) { | ||
cell[0] = cell[1] | ||
currentCell = flushCell( | ||
map, | ||
context, | ||
lastCell, | ||
rowKind, | ||
undefined, | ||
currentCell | ||
) | ||
lastCell = [0, 0, 0, 0] | ||
} | ||
cell[2] = index | ||
} | ||
)(code) | ||
} else if (token.type === 'tableCellDivider') { | ||
if (inFirstCellAwaitingPipe) { | ||
inFirstCellAwaitingPipe = false | ||
} else { | ||
if (lastCell[1] !== 0) { | ||
cell[0] = cell[1] | ||
currentCell = flushCell( | ||
map, | ||
context, | ||
lastCell, | ||
rowKind, | ||
undefined, | ||
currentCell | ||
) | ||
} | ||
lastCell = cell | ||
cell = [lastCell[1], index, 0, 0] | ||
} | ||
} | ||
} | ||
// Exit events. | ||
else if (token.type === 'tableHead') { | ||
afterHeadAwaitingFirstBodyRow = true | ||
lastTableEnd = index | ||
} else if ( | ||
token.type === 'tableRow' || | ||
token.type === 'tableDelimiterRow' | ||
) { | ||
lastTableEnd = index | ||
if (lastCell[1] !== 0) { | ||
cell[0] = cell[1] | ||
currentCell = flushCell( | ||
map, | ||
context, | ||
lastCell, | ||
rowKind, | ||
index, | ||
currentCell | ||
) | ||
} else if (cell[1] !== 0) { | ||
currentCell = flushCell(map, context, cell, rowKind, index, currentCell) | ||
} | ||
rowKind = 0 | ||
} else if ( | ||
rowKind && | ||
(token.type === 'data' || | ||
token.type === 'tableDelimiterMarker' || | ||
token.type === 'tableDelimiterFiller') | ||
) { | ||
cell[3] = index | ||
} | ||
} | ||
if (lastTableEnd !== 0) { | ||
flushTableEnd(map, context, lastTableEnd, currentTable, currentBody) | ||
} | ||
map.consume(context.events) | ||
// To do: move this into `html`, when events are exposed there. | ||
// That’s what `markdown-rs` does. | ||
// That needs updates to `mdast-util-gfm-table`. | ||
index = -1 | ||
while (++index < context.events.length) { | ||
const event = context.events[index] | ||
if (event[0] === 'enter' && event[1].type === 'table') { | ||
// @ts-expect-error: custom field. | ||
event[1]._align = gfmTableAlign(context.events, index) | ||
} | ||
} | ||
return events | ||
} | ||
/** @type {Tokenizer} */ | ||
function tokenizeNextPrefixedOrBlank(effects, ok, nok) { | ||
let size = 0 | ||
return start | ||
/** @type {State} */ | ||
/// Generate a cell. | ||
/** | ||
* | ||
* @param {EditMap} map | ||
* @param {TokenizeContext} context | ||
* @param {Range} range | ||
* @param {RowKind} rowKind | ||
* @param {number | undefined} rowEnd | ||
* @param {Token | undefined} previousCell | ||
* @returns {Token | undefined} | ||
*/ | ||
// eslint-disable-next-line max-params | ||
function flushCell(map, context, range, rowKind, rowEnd, previousCell) { | ||
// `markdown-rs` uses: | ||
// rowKind === 2 ? 'tableDelimiterCell' : 'tableCell' | ||
const groupName = | ||
rowKind === 1 | ||
? 'tableHeader' | ||
: rowKind === 2 | ||
? 'tableDelimiter' | ||
: 'tableData' | ||
// `markdown-rs` uses: | ||
// rowKind === 2 ? 'tableDelimiterCellValue' : 'tableCellText' | ||
const valueName = 'tableContent' | ||
function start(code) { | ||
// This is a check, so we don’t care about tokens, but we open a bogus one | ||
// so we’re valid. | ||
effects.enter('check') // EOL. | ||
// Insert an exit for the previous cell, if there is one. | ||
// | ||
// ```markdown | ||
// > | | aa | bb | cc | | ||
// ^-- exit | ||
// ^^^^-- this cell | ||
// ``` | ||
if (range[0] !== 0) { | ||
previousCell.end = Object.assign({}, getPoint(context.events, range[0])) | ||
map.add(range[0], 0, [['exit', previousCell, context]]) | ||
} | ||
effects.consume(code) | ||
return whitespace | ||
// Insert enter of this cell. | ||
// | ||
// ```markdown | ||
// > | | aa | bb | cc | | ||
// ^-- enter | ||
// ^^^^-- this cell | ||
// ``` | ||
const now = getPoint(context.events, range[1]) | ||
previousCell = { | ||
type: groupName, | ||
start: Object.assign({}, now), | ||
// Note: correct end is set later. | ||
end: Object.assign({}, now) | ||
} | ||
/** @type {State} */ | ||
map.add(range[1], 0, [['enter', previousCell, context]]) | ||
function whitespace(code) { | ||
if (code === -1 || code === 32) { | ||
effects.consume(code) | ||
size++ | ||
return size === 4 ? ok : whitespace | ||
} // EOF or whitespace | ||
// Insert text start at first data start and end at last data end, and | ||
// remove events between. | ||
// | ||
// ```markdown | ||
// > | | aa | bb | cc | | ||
// ^-- enter | ||
// ^-- exit | ||
// ^^^^-- this cell | ||
// ``` | ||
if (range[2] !== 0) { | ||
const relatedStart = getPoint(context.events, range[2]) | ||
const relatedEnd = getPoint(context.events, range[3]) | ||
const valueToken = { | ||
type: valueName, | ||
start: Object.assign({}, relatedStart), | ||
end: Object.assign({}, relatedEnd) | ||
} | ||
map.add(range[2], 0, [['enter', valueToken, context]]) | ||
if (rowKind !== 2) { | ||
// Fix positional info on remaining events | ||
const start = context.events[range[2]] | ||
const end = context.events[range[3]] | ||
start[1].end = Object.assign({}, end[1].end) | ||
start[1].type = 'chunkText' | ||
// @ts-expect-error It’s fine. | ||
start[1].contentType = 'text' | ||
if (code === null || markdownLineEndingOrSpace(code)) { | ||
return ok(code) | ||
} // Anything else. | ||
// Remove if needed. | ||
if (range[3] > range[2] + 1) { | ||
const a = range[2] + 1 | ||
const b = range[3] - range[2] - 1 | ||
map.add(a, b, []) | ||
} | ||
} | ||
map.add(range[3] + 1, 0, [['exit', valueToken, context]]) | ||
} | ||
return nok(code) | ||
// Insert an exit for the last cell, if at the row end. | ||
// | ||
// ```markdown | ||
// > | | aa | bb | cc | | ||
// ^-- exit | ||
// ^^^^^^-- this cell (the last one contains two “between” parts) | ||
// ``` | ||
if (rowEnd !== undefined) { | ||
previousCell.end = Object.assign({}, getPoint(context.events, rowEnd)) | ||
map.add(rowEnd, 0, [['exit', previousCell, context]]) | ||
previousCell = undefined | ||
} | ||
return previousCell | ||
} | ||
/** | ||
* Generate table end (and table body end). | ||
* | ||
* @param {EditMap} map | ||
* @param {TokenizeContext} context | ||
* @param {number} index | ||
* @param {Token} table | ||
* @param {Token | undefined} tableBody | ||
*/ | ||
// eslint-disable-next-line max-params | ||
function flushTableEnd(map, context, index, table, tableBody) { | ||
/** @type {Array<Event>} */ | ||
const exits = [] | ||
const related = getPoint(context.events, index) | ||
if (tableBody) { | ||
tableBody.end = Object.assign({}, related) | ||
exits.push(['exit', tableBody, context]) | ||
} | ||
table.end = Object.assign({}, related) | ||
exits.push(['exit', table, context]) | ||
map.add(index + 1, 0, exits) | ||
} | ||
/** | ||
* @param {Array<Event>} events | ||
* @param {number} index | ||
* @returns {readonly Point} | ||
*/ | ||
function getPoint(events, index) { | ||
const event = events[index] | ||
const side = event[0] === 'enter' ? 'start' : 'end' | ||
return event[1][side] | ||
} |
{ | ||
"name": "micromark-extension-gfm-table", | ||
"version": "1.0.5", | ||
"version": "1.0.6", | ||
"description": "micromark extension to support GFM tables", | ||
@@ -50,3 +50,3 @@ "license": "MIT", | ||
"devDependencies": { | ||
"@types/tape": "^4.0.0", | ||
"@types/node": "^20.0.0", | ||
"c8": "^7.0.0", | ||
@@ -57,15 +57,16 @@ "create-gfm-fixtures": "^1.0.0", | ||
"prettier": "^2.0.0", | ||
"remark-cli": "^10.0.0", | ||
"remark-cli": "^11.0.0", | ||
"remark-preset-wooorm": "^9.0.0", | ||
"rimraf": "^3.0.0", | ||
"tape": "^5.0.0", | ||
"type-coverage": "^2.0.0", | ||
"typescript": "^4.0.0", | ||
"xo": "^0.47.0" | ||
"typescript": "^5.0.0", | ||
"xo": "^0.54.0" | ||
}, | ||
"scripts": { | ||
"build": "rimraf \"dev/**/*.d.ts\" \"test/**/*.d.ts\" && tsc && type-coverage && micromark-build", | ||
"prepack": "npm run build && npm run format", | ||
"build": "tsc --build --clean && tsc --build && type-coverage && micromark-build", | ||
"format": "remark . -qfo && prettier . -w --loglevel warn && xo --fix", | ||
"test-api": "node --conditions development test/index.js", | ||
"test-coverage": "c8 --check-coverage --branches 100 --functions 100 --lines 100 --statements 100 --reporter lcov node --conditions development test/index.js", | ||
"test-api-prod": "node --conditions production test/index.js", | ||
"test-api-dev": "node --conditions development test/index.js", | ||
"test-api": "npm run test-api-dev && npm run test-api-prod", | ||
"test-coverage": "c8 --100 --reporter lcov npm run test-api", | ||
"test": "npm run build && npm run format && npm run test-coverage" | ||
@@ -84,10 +85,20 @@ }, | ||
"rules": { | ||
"node/file-extension-in-import": "off", | ||
"unicorn/no-this-assignment": "off", | ||
"complexity": "off" | ||
} | ||
"max-depth": "off", | ||
"n/file-extension-in-import": "off", | ||
"unicorn/no-this-assignment": "off" | ||
}, | ||
"overrides": [ | ||
{ | ||
"files": [ | ||
"test/**/*.js" | ||
], | ||
"rules": { | ||
"no-await-in-loop": 0 | ||
} | ||
} | ||
] | ||
}, | ||
"remarkConfig": { | ||
"plugins": [ | ||
"preset-wooorm" | ||
"remark-preset-wooorm" | ||
] | ||
@@ -94,0 +105,0 @@ }, |
365
readme.md
@@ -11,4 +11,3 @@ # micromark-extension-gfm-table | ||
**[micromark][]** extension to support GitHub flavored markdown (GFM) | ||
[tables][]. | ||
[micromark][] extensions to support GFM [tables][]. | ||
@@ -24,2 +23,7 @@ ## Contents | ||
* [`gfmTableHtml`](#gfmtablehtml) | ||
* [Bugs](#bugs) | ||
* [Authoring](#authoring) | ||
* [HTML](#html) | ||
* [CSS](#css) | ||
* [Syntax](#syntax) | ||
* [Types](#types) | ||
@@ -34,23 +38,24 @@ * [Compatibility](#compatibility) | ||
This package is a micromark extension to add support for GFM tables. | ||
It matches how tables work on `github.com`. | ||
This package contains extensions that add support for the table syntax enabled | ||
by GFM to [`micromark`][micromark]. | ||
These extensions match github.com. | ||
## When to use this | ||
In many cases, when working with micromark, you’d want to use | ||
[`micromark-extension-gfm`][micromark-extension-gfm] instead, which combines | ||
this package with other GFM features. | ||
This project is useful when you want to support tables in markdown. | ||
When working with syntax trees, you’d want to combine this package with | ||
[`mdast-util-gfm-table`][mdast-util-gfm-table] (or | ||
[`mdast-util-gfm`][mdast-util-gfm] when using `micromark-extension-gfm`). | ||
You can use these extensions when you are working with [`micromark`][micromark]. | ||
To support all GFM features, use | ||
[`micromark-extension-gfm`][micromark-extension-gfm] instead. | ||
These tools are all rather low-level. | ||
In most cases, you’d instead want to use [`remark-gfm`][remark-gfm] with | ||
[remark][]. | ||
When you need a syntax tree, combine this package with | ||
[`mdast-util-gfm-table`][mdast-util-gfm-table]. | ||
All these packages are used in [`remark-gfm`][remark-gfm], which focusses on | ||
making it easier to transform content by abstracting these internals away. | ||
## Install | ||
This package is [ESM only][esm]. | ||
In Node.js (version 12.20+, 14.14+, or 16.0+), install with [npm][]: | ||
In Node.js (version 16+), install with [npm][]: | ||
@@ -61,13 +66,13 @@ ```sh | ||
In Deno with [Skypack][]: | ||
In Deno with [`esm.sh`][esmsh]: | ||
```js | ||
import {gfmTable, gfmTableHtml} from 'https://cdn.skypack.dev/micromark-extension-gfm-table@1?dts' | ||
import {gfmTable, gfmTableHtml} from 'https://esm.sh/micromark-extension-gfm-table@1' | ||
``` | ||
In browsers with [Skypack][]: | ||
In browsers with [`esm.sh`][esmsh]: | ||
```html | ||
<script type="module"> | ||
import {gfmTable, gfmTableHtml} from 'https://cdn.skypack.dev/micromark-extension-gfm-table@1?min' | ||
import {gfmTable, gfmTableHtml} from 'https://esm.sh/micromark-extension-gfm-table@1?bundle' | ||
</script> | ||
@@ -104,7 +109,7 @@ ``` | ||
This package exports the following identifiers: `gfmTable`, `gfmTableHtml`. | ||
This package exports the identifiers [`gfmTable`][api-gfm-table] and | ||
[`gfmTableHtml`][api-gfm-table-html]. | ||
There is no default export. | ||
The export map supports the endorsed | ||
[`development` condition](https://nodejs.org/api/packages.html#packages_resolving_user_conditions). | ||
The export map supports the [`development` condition][development]. | ||
Run `node --conditions development module.js` to get instrumented dev code. | ||
@@ -115,19 +120,279 @@ Without this condition, production code is loaded. | ||
An extension for micromark to parse GFM tables (can be passed in `extensions`). | ||
Extension for `micromark` that can be passed in `extensions` to enable GFM | ||
table syntax ([`Extension`][micromark-extension]). | ||
### `gfmTableHtml` | ||
An extension to compile them to HTML (can be passed in `htmlExtensions`). | ||
Extension for `micromark` that can be passed in `htmlExtensions` to support | ||
GFM tables when serializing to HTML | ||
([`HtmlExtension`][micromark-html-extension]). | ||
## Bugs | ||
GitHub’s own algorithm to parse tables contains a bug. | ||
This bug is not present in this project. | ||
The issue relating to tables is: | ||
* [GFM tables: escaped escapes are incorrectly treated as | ||
escapes](https://github.com/github/cmark-gfm/issues/277) | ||
## Authoring | ||
When authoring markdown with GFM tables, it’s recommended to *always* put | ||
pipes around cells. | ||
Without them, it can be hard to infer whether the table will work, how many | ||
columns there are, and which column you are currently editing. | ||
It is recommended to not use many columns, as it results in very long lines, | ||
making it hard to infer which column you are currently editing. | ||
For larger tables, particularly when cells vary in size, it is recommended | ||
*not* to manually “pad” cell text. | ||
While it can look better, it results in a lot of time spent realigning | ||
everything when a new, longer cell is added or the longest cell removed, as | ||
every row then must be changed. | ||
Other than costing time, it also causes large diffs in Git. | ||
To illustrate, when authoring large tables, it is discouraged to pad cells | ||
like this: | ||
```markdown | ||
| Alpha bravo charlie | delta | | ||
| ------------------- | -----------------: | | ||
| Echo | Foxtrot golf hotel | | ||
``` | ||
Instead, use single spaces (and single filler dashes): | ||
```markdown | ||
| Alpha bravo charlie | delta | | ||
| - | -: | | ||
| Echo | Foxtrot golf hotel | | ||
``` | ||
## HTML | ||
GFM tables relate to several HTML elements: `<table>`, `<tbody>`, `<td>`, | ||
`<th>`, `<thead>`, and `<tr>`. | ||
See | ||
[*§ 4.9.1 The `table` element*][html-table], | ||
[*§ 4.9.5 The `tbody` element*][html-tbody], | ||
[*§ 4.9.9 The `td` element*][html-td], | ||
[*§ 4.9.10 The `th` element*][html-th], | ||
[*§ 4.9.6 The `thead` element*][html-thead], and | ||
[*§ 4.9.8 The `tr` element*][html-tr] | ||
in the HTML spec for more info. | ||
If the alignment of a column is left, right, or center, a deprecated | ||
`align` attribute is added to each `<th>` and `<td>` element belonging to | ||
that column. | ||
That attribute is interpreted by browsers as if a CSS `text-align` property | ||
was included, with its value set to that same keyword. | ||
## CSS | ||
The following CSS is needed to make tables look a bit like GitHub. | ||
For the complete actual CSS see | ||
[`sindresorhus/github-markdown-css`][github-markdown-css] | ||
```css | ||
/* Light theme. */ | ||
:root { | ||
--color-canvas-default: #ffffff; | ||
--color-canvas-subtle: #f6f8fa; | ||
--color-border-default: #d0d7de; | ||
--color-border-muted: hsla(210, 18%, 87%, 1); | ||
} | ||
/* Dark theme. */ | ||
@media (prefers-color-scheme: dark) { | ||
:root { | ||
--color-canvas-default: #0d1117; | ||
--color-canvas-subtle: #161b22; | ||
--color-border-default: #30363d; | ||
--color-border-muted: #21262d; | ||
} | ||
} | ||
table { | ||
border-spacing: 0; | ||
border-collapse: collapse; | ||
display: block; | ||
margin-top: 0; | ||
margin-bottom: 16px; | ||
width: max-content; | ||
max-width: 100%; | ||
overflow: auto; | ||
} | ||
tr { | ||
background-color: var(--color-canvas-default); | ||
border-top: 1px solid var(--color-border-muted); | ||
} | ||
tr:nth-child(2n) { | ||
background-color: var(--color-canvas-subtle); | ||
} | ||
td, | ||
th { | ||
padding: 6px 13px; | ||
border: 1px solid var(--color-border-default); | ||
} | ||
th { | ||
font-weight: 600; | ||
} | ||
table img { | ||
background-color: transparent; | ||
} | ||
``` | ||
## Syntax | ||
Tables form with the following BNF: | ||
```bnf | ||
gfm_table ::= gfm_table_head 0*(eol gfm_table_body_row) | ||
; Restriction: both rows must have the same number of cells. | ||
gfm_table_head ::= gfm_table_row eol gfm_table_delimiter_row | ||
gfm_table_row ::= ['|'] gfm_table_cell 0*('|' gfm_table_cell) ['|'] *space_or_tab | ||
gfm_table_cell ::= *space_or_tab gfm_table_text *space_or_tab | ||
gfm_table_text ::= 0*(line - '\\' - '|' | '\\' ['\\' | '|']) | ||
gfm_table_delimiter_row ::= ['|'] gfm_table_delimiter_cell 0*('|' gfm_table_delimiter_cell) ['|'] *space_or_tab | ||
gfm_table_delimiter_cell ::= *space_or_tab gfm_table_delimiter_value *space_or_tab | ||
gfm_table_delimiter_value ::= [':'] 1*'-' [':'] | ||
``` | ||
As this construct occurs in flow, like all flow constructs, it must be | ||
followed by an eol (line ending) or eof (end of file). | ||
The above grammar shows that basically anything can be a cell or a row. | ||
The main thing that makes something a row, is that it occurs directly before | ||
or after a delimiter row, or after another row. | ||
It is not required for a table to have a body: it can end right after the | ||
delimiter row. | ||
Each column can be marked with an alignment. | ||
The alignment marker is a colon (`:`) used before and/or after delimiter row | ||
filler. | ||
To illustrate: | ||
```markdown | ||
| none | left | right | center | | ||
| ---- | :--- | ----: | :----: | | ||
``` | ||
The number of cells in the delimiter row, is the number of columns of the | ||
table. | ||
Only the head row is required to have the same number of cells. | ||
Body rows are not required to have a certain number of cells. | ||
For body rows that have less cells than the number of columns of the table, | ||
empty cells are injected. | ||
When a row has more cells than the number of columns of the table, the | ||
superfluous cells are dropped. | ||
To illustrate: | ||
```markdown | ||
| a | b | | ||
| - | - | | ||
| c | | ||
| d | e | f | | ||
``` | ||
Yields: | ||
```html | ||
<table> | ||
<thead> | ||
<tr> | ||
<th>a</th> | ||
<th>b</th> | ||
</tr> | ||
</thead> | ||
<tbody> | ||
<tr> | ||
<td>c</td> | ||
<td></td> | ||
</tr> | ||
<tr> | ||
<td>d</td> | ||
<td>e</td> | ||
</tr> | ||
</tbody> | ||
</table> | ||
``` | ||
Each cell’s text is interpreted as the [text][micromark-content-type] content | ||
type. | ||
That means that it can include constructs such as attention (emphasis, strong). | ||
The grammar for cells prohibits the use of `|` in them. | ||
To use pipes in cells, encode them as a character reference or character | ||
escape: `|` (or `|`, `|`, `|`, `|`) or | ||
`\|`. | ||
Escapes will typically work, but they are not supported in | ||
code (text) (and the math (text) extension). | ||
To work around this, GitHub came up with a rather weird “trick”. | ||
When inside a table cell *and* inside code, escaped pipes *are* decoded. | ||
To illustrate: | ||
```markdown | ||
| Name | Character | | ||
| - | - | | ||
| Left curly brace | `{` | | ||
| Pipe | `\|` | | ||
| Right curly brace | `}` | | ||
``` | ||
Yields: | ||
```html | ||
<table> | ||
<thead> | ||
<tr> | ||
<th>Name</th> | ||
<th>Character</th> | ||
</tr> | ||
</thead> | ||
<tbody> | ||
<tr> | ||
<td>Left curly brace</td> | ||
<td><code>{</code></td> | ||
</tr> | ||
<tr> | ||
<td>Pipe</td> | ||
<td><code>|</code></td> | ||
</tr> | ||
<tr> | ||
<td>Right curly brace</td> | ||
<td><code>}</code></td> | ||
</tr> | ||
</tbody> | ||
</table> | ||
``` | ||
> 👉 **Note**: no other character can be escaped like this. | ||
> Escaping pipes in code does not work when not inside a table, either. | ||
## Types | ||
This package is fully typed with [TypeScript][]. | ||
There are no additional exported types. | ||
It exports no additional types. | ||
## Compatibility | ||
This package is at least compatible with all maintained versions of Node.js. | ||
As of now, that is Node.js 12.20+, 14.14+, and 16.0+. | ||
It also works in Deno and modern browsers. | ||
Projects maintained by the unified collective are compatible with all maintained | ||
versions of Node.js. | ||
As of now, that is Node.js 16+. | ||
Our projects sometimes work with older versions, but this is not guaranteed. | ||
These extensions work with `micromark` version 3+. | ||
## Security | ||
@@ -139,8 +404,10 @@ | ||
* [`syntax-tree/mdast-util-gfm-table`][mdast-util-gfm-table] | ||
— support GFM tables in mdast | ||
* [`syntax-tree/mdast-util-gfm`][mdast-util-gfm] | ||
— support GFM in mdast | ||
* [`remarkjs/remark-gfm`][remark-gfm] | ||
— support GFM in remark | ||
* [`micromark-extension-gfm`][micromark-extension-gfm] | ||
— support all of GFM | ||
* [`mdast-util-gfm-table`][mdast-util-gfm-table] | ||
— support all of GFM in mdast | ||
* [`mdast-util-gfm`][mdast-util-gfm] | ||
— support all of GFM in mdast | ||
* [`remark-gfm`][remark-gfm] | ||
— support all of GFM in remark | ||
@@ -191,3 +458,3 @@ ## Contribute | ||
[skypack]: https://www.skypack.dev | ||
[esmsh]: https://esm.sh | ||
@@ -208,14 +475,38 @@ [license]: license | ||
[development]: https://nodejs.org/api/packages.html#packages_resolving_user_conditions | ||
[micromark]: https://github.com/micromark/micromark | ||
[remark]: https://github.com/remarkjs/remark | ||
[micromark-extension]: https://github.com/micromark/micromark#syntaxextension | ||
[micromark-html-extension]: https://github.com/micromark/micromark#htmlextension | ||
[micromark-content-type]: https://github.com/micromark/micromark#content-types | ||
[micromark-extension-gfm]: https://github.com/micromark/micromark-extension-gfm | ||
[mdast-util-gfm]: https://github.com/syntax-tree/mdast-util-gfm | ||
[mdast-util-gfm-table]: https://github.com/syntax-tree/mdast-util-gfm-table | ||
[mdast-util-gfm]: https://github.com/syntax-tree/mdast-util-gfm | ||
[remark-gfm]: https://github.com/remarkjs/remark-gfm | ||
[tables]: https://github.github.com/gfm/#tables-extension- | ||
[html-table]: https://html.spec.whatwg.org/multipage/tables.html#the-table-element | ||
[html-tbody]: https://html.spec.whatwg.org/multipage/tables.html#the-tbody-element | ||
[html-thead]: https://html.spec.whatwg.org/multipage/tables.html#the-thead-element | ||
[html-tr]: https://html.spec.whatwg.org/multipage/tables.html#the-tr-element | ||
[html-td]: https://html.spec.whatwg.org/multipage/tables.html#the-td-element | ||
[html-th]: https://html.spec.whatwg.org/multipage/tables.html#the-th-element | ||
[github-markdown-css]: https://github.com/sindresorhus/github-markdown-css | ||
[api-gfm-table]: #gfmtable | ||
[api-gfm-table-html]: #gfmtablehtml |
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
Major refactor
Supply chain riskPackage has recently undergone a major refactor. It may be unstable or indicate significant internal changes. Use caution when updating to versions that include significant changes.
Found 1 instance in 1 package
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
87642
11
23
2615
504
1