@diplodoc/transform 4.74.1 → 4.75.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,12 +1,14 @@
1
1
  import type StateBlock from 'markdown-it/lib/rules_block/state_block';
2
2
  import type Token from 'markdown-it/lib/token';
3
3
  import type {MarkdownItPluginCb} from '../typings';
4
- import type {YfmTablePluginOptions} from './types';
4
+ import type {Logger} from '../../log';
5
+ import type {TableAttrs, YfmTablePluginOptions} from './types';
5
6
 
6
- import {AttrsParser} from '@diplodoc/utils';
7
+ import {AttrsParser, parseMdAttrs} from '@diplodoc/utils';
7
8
 
8
9
  const pluginName = 'yfm_table';
9
10
  const pipeChar = 0x7c; // |
11
+ const colonChar = 0x3a; // :
10
12
  const apostropheChar = 0x60; // `
11
13
  const hashChar = 0x23; // #
12
14
  const backSlashChar = 0x5c; // \
@@ -225,10 +227,70 @@ class StateIterator {
225
227
  }
226
228
  }
227
229
 
230
+ type CellData = {start: Stats; end: Stats; rawAttrs: TableAttrs};
231
+ type RowData = {start: number; end: number; cols: CellData[]; rawAttrs: TableAttrs};
232
+
228
233
  interface RowPositions {
229
- rows: [number, number, [Stats, Stats][]][];
234
+ rows: RowData[];
230
235
  endOfTable: number | null;
231
236
  pos: number;
237
+ tableAttrs: TableAttrs;
238
+ }
239
+
240
+ /**
241
+ * Parses table options from lines between #| and the first || or |#.
242
+ *
243
+ * Option line format: `|:{key="value" key2="value2"}`
244
+ * Multiple option lines are supported; results are merged.
245
+ */
246
+ function parseTableAttrs(state: StateBlock, iter: StateIterator): TableAttrs {
247
+ const options: TableAttrs = {};
248
+ const {src} = state;
249
+ const utils = state.md.utils;
250
+
251
+ let parsing = true;
252
+
253
+ while (parsing) {
254
+ const lineEnd = iter.lineEnds;
255
+
256
+ // Skip leading whitespace to find first non-space char
257
+ let first = iter.pos;
258
+ while (first < lineEnd && utils.isSpace(src.charCodeAt(first))) {
259
+ first++;
260
+ }
261
+
262
+ // Skip empty lines
263
+ if (first >= lineEnd) {
264
+ iter.next(lineEnd - iter.pos + 1);
265
+ continue;
266
+ }
267
+
268
+ // Stop if we hit row start || or table close |#
269
+ if (isRowOrder(src, first) || isCloseTableOrder(src, first)) {
270
+ parsing = false;
271
+ continue;
272
+ }
273
+
274
+ // Option line must start with |:
275
+ if (src.charCodeAt(first) !== pipeChar || src.charCodeAt(first + 1) !== colonChar) {
276
+ parsing = false;
277
+ continue;
278
+ }
279
+
280
+ const result = parseMdAttrs(state.md, src, first + 2, lineEnd);
281
+ if (!result) {
282
+ parsing = false;
283
+ continue;
284
+ }
285
+
286
+ for (const [key, values] of Object.entries(result.attrs)) {
287
+ options[key] = values[values.length - 1];
288
+ }
289
+
290
+ iter.next(lineEnd - iter.pos + 1);
291
+ }
292
+
293
+ return options;
232
294
  }
233
295
 
234
296
  // eslint-disable-next-line complexity
@@ -241,13 +303,17 @@ function getTableRowPositions(
241
303
  ): RowPositions {
242
304
  let endOfTable = null;
243
305
  let tableLevel = 0;
244
- let currentRow: [Stats, Stats][] = [];
306
+ let currentRow: CellData[] = [];
245
307
  let colStart: Stats | null = null;
308
+ let pendingCellAttrs: TableAttrs = {};
246
309
  let rowStart: number | null = null;
310
+ let currentRowAttrs: TableAttrs = {};
247
311
 
248
312
  const iter = new StateIterator(state, startPosition + openTableOrder.length, startLine);
249
313
 
250
- const rows: [number, number, typeof currentRow][] = [];
314
+ const tableAttrs = parseTableAttrs(state, iter);
315
+
316
+ const rows: RowData[] = [];
251
317
 
252
318
  let isInsideCode = false;
253
319
  let isInsideMath = false;
@@ -256,14 +322,21 @@ function getTableRowPositions(
256
322
 
257
323
  const addRow = () => {
258
324
  if (colStart) {
259
- currentRow.push([colStart, iter.stats()]);
325
+ currentRow.push({start: colStart, end: iter.stats(), rawAttrs: pendingCellAttrs});
326
+ pendingCellAttrs = {};
260
327
  }
261
328
  if (currentRow.length && rowStart) {
262
- rows.push([rowStart, iter.line, currentRow]);
329
+ rows.push({
330
+ start: rowStart,
331
+ end: iter.line,
332
+ cols: currentRow,
333
+ rawAttrs: currentRowAttrs,
334
+ });
263
335
  }
264
336
  currentRow = [];
265
337
  colStart = null;
266
338
  rowStart = null;
339
+ currentRowAttrs = {};
267
340
  };
268
341
 
269
342
  while (iter.pos <= endPosition) {
@@ -356,6 +429,36 @@ function getTableRowPositions(
356
429
  } else {
357
430
  iter.next(rowStartOrder.length);
358
431
  rowStart = iter.line;
432
+
433
+ const hasRowAttrs =
434
+ iter.pos <= iter.lineEnds &&
435
+ state.src.charCodeAt(iter.pos) === colonChar &&
436
+ state.src.charCodeAt(iter.pos + 1) === curlyBraceOpen;
437
+ if (hasRowAttrs) {
438
+ currentRowAttrs = parseInlineAttrs(state, iter, 1);
439
+ } else {
440
+ currentRowAttrs = {};
441
+ }
442
+
443
+ // skip spaces/tabs on same line only if row attrs were present, then check for first-cell attrs ::{...}
444
+ if (hasRowAttrs) {
445
+ while (
446
+ iter.pos <= iter.lineEnds &&
447
+ state.md.utils.isSpace(state.src.charCodeAt(iter.pos))
448
+ ) {
449
+ iter.next(1);
450
+ }
451
+ }
452
+ if (
453
+ iter.pos <= iter.lineEnds &&
454
+ state.src.charCodeAt(iter.pos) === colonChar &&
455
+ state.src.charCodeAt(iter.pos + 1) === colonChar
456
+ ) {
457
+ pendingCellAttrs = parseInlineAttrs(state, iter, 2);
458
+ } else {
459
+ pendingCellAttrs = {};
460
+ }
461
+
359
462
  colStart = iter.stats();
360
463
  }
361
464
 
@@ -366,9 +469,20 @@ function getTableRowPositions(
366
469
 
367
470
  if (isCellOrder(state.src, iter.pos)) {
368
471
  if (colStart) {
369
- currentRow.push([colStart, iter.stats()]);
472
+ currentRow.push({start: colStart, end: iter.stats(), rawAttrs: pendingCellAttrs});
473
+ pendingCellAttrs = {};
370
474
  }
371
475
  iter.next(cellStartOrder.length);
476
+ // check for cell attrs ::{...} immediately after | (no spaces allowed)
477
+ if (
478
+ iter.pos <= iter.lineEnds &&
479
+ state.src.charCodeAt(iter.pos) === colonChar &&
480
+ state.src.charCodeAt(iter.pos + 1) === colonChar
481
+ ) {
482
+ pendingCellAttrs = parseInlineAttrs(state, iter, 2);
483
+ } else {
484
+ pendingCellAttrs = {};
485
+ }
372
486
  colStart = iter.stats();
373
487
  continue;
374
488
  }
@@ -378,7 +492,20 @@ function getTableRowPositions(
378
492
 
379
493
  const {pos} = iter;
380
494
 
381
- return {rows, endOfTable, pos};
495
+ return {rows, endOfTable, pos, tableAttrs};
496
+ }
497
+
498
+ function parseInlineAttrs(state: StateBlock, iter: StateIterator, prefixLen: number): TableAttrs {
499
+ const result: TableAttrs = {};
500
+ const parsed = parseMdAttrs(state.md, state.src, iter.pos + prefixLen, iter.lineEnds);
501
+ if (!parsed) {
502
+ return result;
503
+ }
504
+ for (const [key, values] of Object.entries(parsed.attrs)) {
505
+ result[key] = values[0];
506
+ }
507
+ iter.next(parsed.pos - iter.pos);
508
+ return result;
382
509
  }
383
510
 
384
511
  function extractAttributes(state: StateBlock, pos: number): Record<string, string[]> {
@@ -396,9 +523,10 @@ function extractAttributes(state: StateBlock, pos: number): Record<string, strin
396
523
  *
397
524
  * @param {Token} contentToken - Search the content of this token for the class.
398
525
  * @param {Token} tdOpenToken - Parent td_open token. Extracted class is applied to this token.
526
+ * @param {Logger} log - Logger instance for deprecation warnings.
399
527
  * @returns {void}
400
528
  */
401
- function extractAndApplyClassFromToken(contentToken: Token, tdOpenToken: Token): void {
529
+ function extractAndApplyClassFromToken(contentToken: Token, tdOpenToken: Token, log: Logger): void {
402
530
  // Regex to find class attribute in any position within brackets
403
531
  const blockRegex = /\s*\{[^}]*}$/;
404
532
  const allAttrs = contentToken.content.match(blockRegex);
@@ -410,7 +538,12 @@ function extractAndApplyClassFromToken(contentToken: Token, tdOpenToken: Token):
410
538
  const attrsClass = attrs?.class?.join(' ');
411
539
 
412
540
  if (attrsClass) {
413
- tdOpenToken.attrSet('class', attrsClass);
541
+ if (attrsClass.split(' ').some((c) => c.startsWith('cell-align-'))) {
542
+ log.warn(
543
+ `Deprecated: use align="..." inside cell attributes "::{align=\\"...\\"}" instead of .cell-align-* class in cell content "{ }"`,
544
+ );
545
+ }
546
+ tdOpenToken.attrJoin('class', attrsClass);
414
547
  // remove the class from the token so that it's not propagated to tr or table level
415
548
  let replacedContent = allAttrs[0].replace(`.${attrsClass}`, '');
416
549
  if (replacedContent.trim() === '{}') {
@@ -420,6 +553,52 @@ function extractAndApplyClassFromToken(contentToken: Token, tdOpenToken: Token):
420
553
  }
421
554
  }
422
555
 
556
+ const VALID_ALIGN_VALUES: ReadonlySet<string> = new Set([
557
+ 'top-left',
558
+ 'top-center',
559
+ 'top-right',
560
+ 'center',
561
+ 'bottom-left',
562
+ 'bottom-right',
563
+ ]);
564
+
565
+ function parseHeaderRows(rawAttrs: TableAttrs, log: Logger): number | null {
566
+ if (!('header-rows' in rawAttrs)) return null;
567
+ const raw = rawAttrs['header-rows'];
568
+ const trimmed = raw.trim();
569
+ const parsed = Number.parseInt(trimmed, 10);
570
+ if (!Number.isInteger(parsed) || parsed <= 0 || String(parsed) !== trimmed) {
571
+ log.warn(`Invalid table header-rows value: "${raw}"`);
572
+ return null;
573
+ }
574
+ return parsed;
575
+ }
576
+
577
+ function applyCellAttrs(token: Token, rawAttrs: TableAttrs, log: Logger): void {
578
+ if ('align' in rawAttrs) {
579
+ const value = rawAttrs['align'];
580
+ if (!VALID_ALIGN_VALUES.has(value)) {
581
+ log.warn(`Unknown table cell align value: "${value}"`);
582
+ }
583
+ token.meta = token.meta || {};
584
+ token.meta.align = value;
585
+ token.attrSet('data-align', value);
586
+ if (value) {
587
+ token.attrJoin('class', `cell-align-${value}`);
588
+ }
589
+ }
590
+
591
+ if ('bg' in rawAttrs) {
592
+ const value = rawAttrs['bg'];
593
+ token.meta = token.meta || {};
594
+ token.meta.bg = value;
595
+ token.attrSet('data-bg', value);
596
+ if (value) {
597
+ token.attrJoin('class', `cell-bg-${value}`);
598
+ }
599
+ }
600
+ }
601
+
423
602
  const COLSPAN_SYMBOL = '>';
424
603
  const ROWSPAN_SYMBOL = '^';
425
604
 
@@ -539,7 +718,7 @@ const yfmTable: MarkdownItPluginCb<YfmTablePluginOptions> = (md, opts) => {
539
718
  return true;
540
719
  }
541
720
 
542
- const {rows, endOfTable, pos} = getTableRowPositions(
721
+ const {rows, endOfTable, pos, tableAttrs} = getTableRowPositions(
543
722
  state,
544
723
  startPosition,
545
724
  endPosition,
@@ -575,11 +754,19 @@ const yfmTable: MarkdownItPluginCb<YfmTablePluginOptions> = (md, opts) => {
575
754
  }
576
755
 
577
756
  token.map = [startLine, endOfTable];
757
+ token.meta = token.meta || {};
758
+ token.meta.rawAttrs = tableAttrs;
759
+
760
+ const headerRows = parseHeaderRows(tableAttrs, opts.log);
761
+ if (headerRows !== null) {
762
+ token.meta.headerRows = headerRows;
763
+ token.attrSet('data-header-rows', String(headerRows));
764
+ }
578
765
 
579
766
  token = state.push('yfm_tbody_open', 'tbody', 1);
580
767
  token.map = [startLine + 1, endOfTable - 1];
581
768
 
582
- const maxRowLength = Math.max(...rows.map(([, , cols]) => cols.length));
769
+ const maxRowLength = Math.max(...rows.map((row) => row.cols.length));
583
770
 
584
771
  // cellsMaps is a 2-D map of all td_open tokens in the table.
585
772
  // cellsMap is used to access the table cells by [row][column] coordinates
@@ -590,19 +777,38 @@ const yfmTable: MarkdownItPluginCb<YfmTablePluginOptions> = (md, opts) => {
590
777
  const contentMap: string[][] = [];
591
778
 
592
779
  for (let i = 0; i < rows.length; i++) {
593
- const [rowLineStarts, rowLineEnds, cols] = rows[i];
780
+ const {
781
+ start: rowLineStarts,
782
+ end: rowLineEnds,
783
+ cols,
784
+ rawAttrs: rowRawAttrs,
785
+ } = rows[i];
594
786
  cellsMap.push([]);
595
787
  contentMap.push([]);
596
788
  const rowLength = cols.length;
597
789
 
790
+ const isHeaderRow = headerRows !== null && i < headerRows;
791
+ const cellTag = isHeaderRow ? 'th' : 'td';
792
+
598
793
  token = state.push('yfm_tr_open', 'tr', 1);
599
794
  token.map = [rowLineStarts, rowLineEnds];
795
+ token.meta = token.meta || {};
796
+ token.meta.rawAttrs = rowRawAttrs;
797
+ if (isHeaderRow) {
798
+ token.attrSet('data-header', 'true');
799
+ }
600
800
 
601
801
  for (let j = 0; j < cols.length; j++) {
602
- const [begin, end] = cols[j];
603
- token = state.push('yfm_td_open', 'td', 1);
802
+ const {start: begin, end, rawAttrs: cellRawAttrs} = cols[j];
803
+ token = state.push('yfm_td_open', cellTag, 1);
604
804
  cellsMap[i].push(token);
605
805
  token.map = [begin.line, end.line];
806
+ token.meta = token.meta || {};
807
+ token.meta.rawAttrs = cellRawAttrs;
808
+ if (isHeaderRow) {
809
+ token.attrSet('scope', 'col');
810
+ }
811
+ applyCellAttrs(token, cellRawAttrs, opts.log);
606
812
 
607
813
  const oldTshift = state.tShift[begin.line];
608
814
  const oldEMark = state.eMarks[end.line];
@@ -622,7 +828,7 @@ const yfmTable: MarkdownItPluginCb<YfmTablePluginOptions> = (md, opts) => {
622
828
  const content = contentToken.content.trim() || contentToken.markup.trim();
623
829
  contentMap[i].push(content);
624
830
 
625
- token = state.push('yfm_td_close', 'td', -1);
831
+ token = state.push('yfm_td_close', cellTag, -1);
626
832
  state.tokens[state.tokens.length - 1].map = [end.line, end.line + 1];
627
833
 
628
834
  state.lineMax = oldLineMax;
@@ -631,14 +837,21 @@ const yfmTable: MarkdownItPluginCb<YfmTablePluginOptions> = (md, opts) => {
631
837
  state.eMarks[end.line] = oldEMark;
632
838
 
633
839
  const rowTokens = cellsMap[cellsMap.length - 1];
634
- extractAndApplyClassFromToken(contentToken, rowTokens[rowTokens.length - 1]);
840
+ extractAndApplyClassFromToken(
841
+ contentToken,
842
+ rowTokens[rowTokens.length - 1],
843
+ opts.log,
844
+ );
635
845
  }
636
846
 
637
847
  if (rowLength < maxRowLength) {
638
848
  const emptyCellsCount = maxRowLength - rowLength;
639
849
  for (let k = 0; k < emptyCellsCount; k++) {
640
- token = state.push('yfm_td_open', 'td', 1);
641
- token = state.push('yfm_td_close', 'td', -1);
850
+ token = state.push('yfm_td_open', cellTag, 1);
851
+ if (isHeaderRow) {
852
+ token.attrSet('scope', 'col');
853
+ }
854
+ token = state.push('yfm_td_close', cellTag, -1);
642
855
  }
643
856
  }
644
857
 
@@ -1,3 +1,5 @@
1
+ export type TableAttrs = Record<string, string>;
2
+
1
3
  export type YfmTablePluginOptions = {
2
4
  /**
3
5
  * Sets whether to interpret the pipe symbol inside the code block as a table separator.