Skip to content

Commit cafabdf

Browse files
authored
feat(table): add support for row/col spans in multiline tables
1 parent 30272fe commit cafabdf

File tree

4 files changed

+810
-6
lines changed

4 files changed

+810
-6
lines changed

src/scss/_table.scss

+32
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
.yfm table td {
2+
&.cell-align-top-left,
3+
&.cell-align-bottom-left {
4+
text-align: start;
5+
}
6+
7+
&.cell-align-top-center,
8+
&.cell-align-center {
9+
text-align: center;
10+
}
11+
12+
&.cell-align-top-right,
13+
&.cell-align-bottom-right {
14+
text-align: end;
15+
}
16+
17+
&.cell-align-top-left,
18+
&.cell-align-top-center,
19+
&.cell-align-top-right {
20+
vertical-align: top;
21+
}
22+
23+
&.cell-align-center {
24+
vertical-align: middle;
25+
}
26+
27+
&.cell-align-bottom-left,
28+
&.cell-align-bottom-right {
29+
vertical-align: bottom;
30+
}
31+
}
32+

src/scss/yfm.scss

+1
Original file line numberDiff line numberDiff line change
@@ -6,5 +6,6 @@
66
@import 'cut';
77
@import 'file';
88
@import 'term';
9+
@import 'table';
910

1011
@import '@diplodoc/tabs-extension/runtime';

src/transform/plugins/table/index.ts

+178-6
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import StateBlock from 'markdown-it/lib/rules_block/state_block';
22
import {MarkdownItPluginCb} from '../typings';
3+
import Token from 'markdown-it/lib/token';
34

45
const pluginName = 'yfm_table';
56
const pipeChar = 0x7c; // |
@@ -91,12 +92,17 @@ class StateIterator {
9192
}
9293
}
9394

94-
function getTableRows(
95+
interface RowPositions {
96+
rows: [number, number, [Stats, Stats][]][];
97+
endOfTable: number | null;
98+
}
99+
100+
function getTableRowPositions(
95101
state: StateBlock,
96102
startPosition: number,
97103
endPosition: number,
98104
startLine: number,
99-
) {
105+
): RowPositions {
100106
let endOfTable = null;
101107
let tableLevel = 0;
102108
let currentRow: [Stats, Stats][] = [];
@@ -210,6 +216,144 @@ function getTableRows(
210216
return {rows, endOfTable};
211217
}
212218

219+
/**
220+
* Removes the specified attribute from attributes in the content of a token.
221+
*
222+
* @param {Token} contentToken - The target token.
223+
* @param {string} attr - The attribute to be removed from the token content.
224+
*
225+
* @return {void}
226+
*/
227+
function removeAttrFromTokenContent(contentToken: Token, attr: string): void {
228+
// Replace the attribute in the token content with an empty string.
229+
const blockRegex = /\s*\{[^}]*}/;
230+
const allAttrs = contentToken.content.match(blockRegex);
231+
if (!allAttrs) {
232+
return;
233+
}
234+
let replacedContent = allAttrs[0].replace(`.${attr}`, '');
235+
if (replacedContent.trim() === '{}') {
236+
replacedContent = '';
237+
}
238+
contentToken.content = contentToken.content.replace(allAttrs[0], replacedContent);
239+
}
240+
241+
/**
242+
* Extracts the class attribute from the given content token and applies it to the tdOpenToken.
243+
* Preserves other attributes.
244+
*
245+
* @param {Token} contentToken - Search the content of this token for the class.
246+
* @param {Token} tdOpenToken - Parent td_open token. Extracted class is applied to this token.
247+
* @returns {void}
248+
*/
249+
function extractAndApplyClassFromToken(contentToken: Token, tdOpenToken: Token): void {
250+
// Regex to find class attribute in any position within brackets
251+
const classAttrRegex = /(?<=\{[^}]*)\.([-_a-zA-Z0-9]+)/g;
252+
const classAttrMatch = classAttrRegex.exec(contentToken.content);
253+
if (classAttrMatch) {
254+
const classAttr = classAttrMatch[1];
255+
tdOpenToken.attrSet('class', classAttr);
256+
removeAttrFromTokenContent(contentToken, classAttr);
257+
}
258+
}
259+
260+
const COLSPAN_SYMBOL = '>';
261+
const ROWSPAN_SYMBOL = '^';
262+
263+
/**
264+
* Traverses through the content map, applying row/colspan attributes and marking the special cells for deletion.
265+
* Upon encountering a symbol denoting a row span or a column span, proceed backwards in row or column
266+
* until text cell is found. Upon finding the text cell, store the colspan or rowspan value.
267+
* During the backward traversal, if the same symbol is encountered, increment the value of rowspan/colspan.
268+
* Colspan symbol is ignored for the first column. Rowspan symbol is ignored for the first row
269+
*
270+
* @param contentMap string[][]
271+
* @param tokenMap Token[][]
272+
* @return {void}
273+
*/
274+
const applySpans = (contentMap: string[][], tokenMap: Token[][]): void => {
275+
for (let i = 0; i < contentMap.length; i++) {
276+
for (let j = 0; j < contentMap[0].length; j++) {
277+
if (contentMap[i][j] === COLSPAN_SYMBOL) {
278+
// skip the first column
279+
if (j === 0) {
280+
continue;
281+
}
282+
tokenMap[i][j].meta = {markForDeletion: true};
283+
let colspanFactor = 2;
284+
// traverse columns backwards
285+
for (let col = j - 1; col >= 0; col--) {
286+
if (contentMap[i][col] === COLSPAN_SYMBOL) {
287+
colspanFactor++;
288+
tokenMap[i][col].meta = {markForDeletion: true};
289+
} else if (contentMap[i][col] === ROWSPAN_SYMBOL) {
290+
// Do nothing, this should be applied on the row that's being extended
291+
break;
292+
} else {
293+
tokenMap[i][col].attrSet('colspan', colspanFactor.toString());
294+
break;
295+
}
296+
}
297+
}
298+
299+
if (contentMap[i][j] === ROWSPAN_SYMBOL) {
300+
// skip the first row
301+
if (i === 0) {
302+
continue;
303+
}
304+
tokenMap[i][j].meta = {markForDeletion: true};
305+
let rowSpanFactor = 2;
306+
// traverse rows upward
307+
for (let row = i - 1; row >= 0; row--) {
308+
if (contentMap[row][j] === ROWSPAN_SYMBOL) {
309+
rowSpanFactor++;
310+
tokenMap[row][j].meta = {markForDeletion: true};
311+
} else if (contentMap[row][j] === COLSPAN_SYMBOL) {
312+
break;
313+
} else {
314+
tokenMap[row][j].attrSet('rowspan', rowSpanFactor.toString());
315+
break;
316+
}
317+
}
318+
}
319+
}
320+
}
321+
};
322+
323+
/**
324+
* Removes td_open and matching td_close tokens and the content within them
325+
*
326+
* @param {number} tableStart - The index of the start of the table in the state tokens array.
327+
* @param {Token[]} tokens - The array of tokens from state.
328+
* @returns {void}
329+
*/
330+
const clearTokens = (tableStart: number, tokens: Token[]): void => {
331+
// use splices array to avoid modifying the tokens array during iteration
332+
const splices: number[][] = [];
333+
for (let i = tableStart; i < tokens.length; i++) {
334+
if (tokens[i].meta?.markForDeletion) {
335+
// Use unshift instead of push so that the splices indexes are in reverse order.
336+
// Reverse order guarantees that we don't mess up the indexes while removing the items.
337+
splices.unshift([i]);
338+
const level = tokens[i].level;
339+
// find matching td_close with the same level
340+
for (let j = i + 1; j < tokens.length; j++) {
341+
if (tokens[j].type === 'yfm_td_close' && tokens[j].level === level) {
342+
splices[0].push(j);
343+
break;
344+
}
345+
}
346+
}
347+
}
348+
splices.forEach(([start, end]) => {
349+
// check that we have both start and end defined
350+
// it's possible we didn't find td_close index
351+
if (start && end) {
352+
tokens.splice(start, end - start + 1);
353+
}
354+
});
355+
};
356+
213357
const yfmTable: MarkdownItPluginCb = (md) => {
214358
md.block.ruler.before(
215359
'code',
@@ -232,7 +376,12 @@ const yfmTable: MarkdownItPluginCb = (md) => {
232376
return true;
233377
}
234378

235-
const {rows, endOfTable} = getTableRows(state, startPosition, endPosition, startLine);
379+
const {rows, endOfTable} = getTableRowPositions(
380+
state,
381+
startPosition,
382+
endPosition,
383+
startLine,
384+
);
236385

237386
if (!endOfTable) {
238387
token = state.push('__yfm_lint', '', 0);
@@ -247,6 +396,7 @@ const yfmTable: MarkdownItPluginCb = (md) => {
247396
state.lineMax = endOfTable;
248397
state.line = startLine;
249398

399+
const tableStart = state.tokens.length;
250400
token = state.push('yfm_table_open', 'table', 1);
251401
token.map = [startLine, endOfTable];
252402

@@ -255,9 +405,18 @@ const yfmTable: MarkdownItPluginCb = (md) => {
255405

256406
const maxRowLength = Math.max(...rows.map(([, , cols]) => cols.length));
257407

408+
// cellsMaps is a 2-D map of all td_open tokens in the table.
409+
// cellsMap is used to access the table cells by [row][column] coordinates
410+
const cellsMap: Token[][] = [];
411+
412+
// contentMap is a 2-D map of the text content within cells in the table.
413+
// To apply spans, traverse the contentMap and modify the cells from cellsMap
414+
const contentMap: string[][] = [];
415+
258416
for (let i = 0; i < rows.length; i++) {
259417
const [rowLineStarts, rowLineEnds, cols] = rows[i];
260-
418+
cellsMap.push([]);
419+
contentMap.push([]);
261420
const rowLength = cols.length;
262421

263422
token = state.push('yfm_tr_open', 'tr', 1);
@@ -266,6 +425,7 @@ const yfmTable: MarkdownItPluginCb = (md) => {
266425
for (let j = 0; j < cols.length; j++) {
267426
const [begin, end] = cols[j];
268427
token = state.push('yfm_td_open', 'td', 1);
428+
cellsMap[i].push(token);
269429
token.map = [begin.line, end.line];
270430

271431
const oldTshift = state.tShift[begin.line];
@@ -279,14 +439,23 @@ const yfmTable: MarkdownItPluginCb = (md) => {
279439
state.lineMax = end.line + 1;
280440

281441
state.md.block.tokenize(state, begin.line, end.line + 1);
442+
const contentToken = state.tokens[state.tokens.length - 2];
443+
444+
// In case of ">" within a cell without whitespace it gets consumed as a blockquote.
445+
// To handle that, check markup as well
446+
const content = contentToken.content.trim() || contentToken.markup.trim();
447+
contentMap[i].push(content);
448+
449+
token = state.push('yfm_td_close', 'td', -1);
450+
state.tokens[state.tokens.length - 1].map = [end.line, end.line + 1];
282451

283452
state.lineMax = oldLineMax;
284453
state.tShift[begin.line] = oldTshift;
285454
state.bMarks[begin.line] = oldBMark;
286455
state.eMarks[end.line] = oldEMark;
287456

288-
token = state.push('yfm_td_close', 'td', -1);
289-
state.tokens[state.tokens.length - 1].map = [end.line, end.line + 1];
457+
const rowTokens = cellsMap[cellsMap.length - 1];
458+
extractAndApplyClassFromToken(contentToken, rowTokens[rowTokens.length - 1]);
290459
}
291460

292461
if (rowLength < maxRowLength) {
@@ -300,6 +469,9 @@ const yfmTable: MarkdownItPluginCb = (md) => {
300469
token = state.push('yfm_tr_close', 'tr', -1);
301470
}
302471

472+
applySpans(contentMap, cellsMap);
473+
clearTokens(tableStart, state.tokens);
474+
303475
token = state.push('yfm_tbody_close', 'tbody', -1);
304476

305477
token = state.push('yfm_table_close', 'table', -1);

0 commit comments

Comments
 (0)