@podlite/schema 0.0.38 → 0.0.40

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.
@@ -9,13 +9,302 @@ Object.defineProperty(exports, "__esModule", { value: true });
9
9
  * and the remaining content is treated as the definition for the term.
10
10
  */
11
11
  const makeTransformer_1 = __importDefault(require("./helpers/makeTransformer"));
12
+ const config_1 = __importDefault(require("./helpers/config"));
12
13
  function flattenDeep(arr) {
13
14
  return arr.reduce((acc, val) => (Array.isArray(val) ? acc.concat(flattenDeep(val)) : acc.concat(val)), []);
14
15
  }
16
+ // ─── CSV source for =table (spec §1672) ────────────────────────────────────
17
+ // Parse CSV content per RFC 4180: comma delimiter, `"`-quoted fields with
18
+ // `""` as embedded-quote escape. Supports LF or CRLF line endings. Leading
19
+ // indentation typical of =data block bodies is stripped from each line.
20
+ function parseCsv(text) {
21
+ const rows = [];
22
+ let field = '';
23
+ let row = [];
24
+ let inQuote = false;
25
+ let i = 0;
26
+ while (i < text.length) {
27
+ const c = text[i];
28
+ if (inQuote) {
29
+ if (c === '"' && text[i + 1] === '"') {
30
+ field += '"';
31
+ i += 2;
32
+ continue;
33
+ }
34
+ if (c === '"') {
35
+ inQuote = false;
36
+ i++;
37
+ continue;
38
+ }
39
+ field += c;
40
+ i++;
41
+ continue;
42
+ }
43
+ if (c === '"' && field === '') {
44
+ inQuote = true;
45
+ i++;
46
+ continue;
47
+ }
48
+ if (c === ',') {
49
+ row.push(field);
50
+ field = '';
51
+ i++;
52
+ continue;
53
+ }
54
+ if (c === '\r') {
55
+ i++;
56
+ continue;
57
+ }
58
+ if (c === '\n') {
59
+ row.push(field);
60
+ rows.push(row);
61
+ row = [];
62
+ field = '';
63
+ i++;
64
+ continue;
65
+ }
66
+ field += c;
67
+ i++;
68
+ }
69
+ if (field !== '' || row.length > 0) {
70
+ row.push(field);
71
+ rows.push(row);
72
+ }
73
+ // Drop rows that are entirely blank (empty content after trim).
74
+ return rows.filter(r => !(r.length === 1 && r[0].trim() === ''));
75
+ }
76
+ // Parse a TSV (tab-separated values) blob. Unlike CSV, TSV has no quoting
77
+ // mechanism — fields are split strictly on tabs and `"` is a literal
78
+ // character. Tabs and newlines inside fields are not representable in TSV.
79
+ function parseTsv(text) {
80
+ const lines = text.split(/\r?\n/);
81
+ const rows = lines.map(line => line.split('\t'));
82
+ return rows.filter(r => !(r.length === 1 && r[0].trim() === ''));
83
+ }
84
+ // Parse a MIME type value into the bare type and a map of parameters
85
+ // (RFC 6838 / RFC 7231 §3.1.1.1). Used to recognise the `header` parameter
86
+ // (RFC 4180 §3) on `:mime-type` of CSV/TSV =data blocks.
87
+ //
88
+ // Examples:
89
+ // "text/csv" → { type: 'text/csv', params: {} }
90
+ // "text/csv; header=present" → { type: 'text/csv', params: { header: 'present' } }
91
+ // "text/csv;charset=utf-8;header=absent" → { ..., params: { charset: 'utf-8', header: 'absent' } }
92
+ function parseMimeType(raw) {
93
+ if (!raw || typeof raw !== 'string')
94
+ return { type: '', params: {} };
95
+ const parts = raw.split(';').map(s => s.trim());
96
+ const type = (parts.shift() || '').toLowerCase();
97
+ const params = {};
98
+ for (const p of parts) {
99
+ if (!p)
100
+ continue;
101
+ const eq = p.indexOf('=');
102
+ if (eq < 0)
103
+ continue;
104
+ const key = p.slice(0, eq).trim().toLowerCase();
105
+ let value = p.slice(eq + 1).trim();
106
+ if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
107
+ value = value.slice(1, -1);
108
+ }
109
+ if (key)
110
+ params[key] = value;
111
+ }
112
+ return { type, params };
113
+ }
114
+ // Locate the first `=data` block with a matching `:key` attribute anywhere
115
+ // in the document tree.
116
+ function findDataBlockByKey(tree, key) {
117
+ let found = null;
118
+ const walk = node => {
119
+ if (found)
120
+ return;
121
+ if (Array.isArray(node)) {
122
+ node.forEach(walk);
123
+ return;
124
+ }
125
+ if (!node || typeof node !== 'object')
126
+ return;
127
+ if (node.type === 'block' && node.name === 'data') {
128
+ const attrs = (0, config_1.default)(node, {});
129
+ if (attrs.getFirstValue('key') === key) {
130
+ found = node;
131
+ return;
132
+ }
133
+ }
134
+ if (Array.isArray(node.content))
135
+ node.content.forEach(walk);
136
+ };
137
+ walk(tree);
138
+ return found;
139
+ }
140
+ function extractDataText(dataNode) {
141
+ if (!dataNode || !Array.isArray(dataNode.content))
142
+ return '';
143
+ const verbatim = dataNode.content.find(c => c && c.type === 'verbatim');
144
+ return verbatim && typeof verbatim.value === 'string' ? verbatim.value : '';
145
+ }
146
+ // Detect whether the =table body is a single-line source reference like
147
+ // `data:key` or `file:path` (spec §1672). Returns the parsed reference or
148
+ // null if the body is ordinary table content.
149
+ function detectSourceReference(tableNode) {
150
+ const texts = [];
151
+ const walker = (0, makeTransformer_1.default)({
152
+ 'row:text': r => {
153
+ texts.push(r.value);
154
+ return r;
155
+ },
156
+ 'head:text': h => {
157
+ texts.push(h.value);
158
+ return h;
159
+ },
160
+ });
161
+ walker(tableNode, {});
162
+ const joined = texts.join('\n').trim();
163
+ const lines = joined
164
+ .split(/\r?\n/)
165
+ .map(l => l.trim())
166
+ .filter(l => l.length > 0);
167
+ if (lines.length !== 1)
168
+ return null;
169
+ const m = lines[0].match(/^(data|file):(\S+)$/);
170
+ if (!m)
171
+ return null;
172
+ return { scheme: m[1], target: m[2] };
173
+ }
174
+ function buildCellBlock(text) {
175
+ return {
176
+ name: 'cell',
177
+ type: 'block',
178
+ margin: '',
179
+ content: [{ type: 'text', value: text }],
180
+ };
181
+ }
182
+ function buildRowBlock(cells, isHeader) {
183
+ const block = {
184
+ name: 'row',
185
+ type: 'block',
186
+ margin: '',
187
+ content: cells,
188
+ };
189
+ if (isHeader) {
190
+ block.config = [{ name: 'header', value: true, type: 'boolean' }];
191
+ }
192
+ return block;
193
+ }
194
+ // Convert parsed CSV/TSV rows to plain `=row`/`=cell` blocks. When
195
+ // `hasHeader` is true (signalled by the `header=present` MIME parameter on
196
+ // the source `=data` block, per RFC 4180 §3), the first row is marked with
197
+ // `:header`. Default behaviour leaves all rows unmarked, matching authors
198
+ // who use a structured table with explicit `=begin row :header` or a
199
+ // Markdown GFM table.
200
+ function csvToTableContent(csvRows, hasHeader = false) {
201
+ return csvRows.map((row, i) => {
202
+ const cells = row.map(v => buildCellBlock(v.trim()));
203
+ return buildRowBlock(cells, hasHeader && i === 0);
204
+ });
205
+ }
206
+ // ─── Error recovery (design notes Rules 2-4) ───────────────────────────────
207
+ // Rule 2 — table-level cell count validation. Pad short rows with empty
208
+ // cells; truncate long rows. Emit a warning whenever a row is changed.
209
+ // Expected count is taken from the `:header` row if present, otherwise from
210
+ // the row with the maximum cell count.
211
+ //
212
+ // Skipped when any cell uses `:colspan` or `:rowspan`: a spanning cell
213
+ // occupies multiple columns, so naive cell counting would misreport row
214
+ // width and drop legitimate spanned cells.
215
+ function normalizeCellCounts(tableNode, source = 'table') {
216
+ if (!tableNode || !Array.isArray(tableNode.content))
217
+ return tableNode;
218
+ const rows = tableNode.content.filter(c => c && c.type === 'block' && c.name === 'row');
219
+ if (rows.length === 0)
220
+ return tableNode;
221
+ const cellsOf = row => (Array.isArray(row.content) ? row.content.filter(c => c && c.name === 'cell') : []);
222
+ const isHeaderRow = row => Array.isArray(row.config) && row.config.some(a => a.name === 'header' && a.value === true);
223
+ const cellHasSpan = cell => Array.isArray(cell.config) && cell.config.some(a => a.name === 'colspan' || a.name === 'rowspan');
224
+ const rowHasSpan = row => cellsOf(row).some(cellHasSpan);
225
+ if (rows.some(rowHasSpan))
226
+ return tableNode;
227
+ const headerRow = rows.find(isHeaderRow);
228
+ const expected = headerRow ? cellsOf(headerRow).length : Math.max(...rows.map(r => cellsOf(r).length));
229
+ if (expected === 0)
230
+ return tableNode;
231
+ let mutated = false;
232
+ const newContent = tableNode.content.map(child => {
233
+ if (!child || child.type !== 'block' || child.name !== 'row')
234
+ return child;
235
+ const cells = cellsOf(child);
236
+ if (cells.length === expected)
237
+ return child;
238
+ if (cells.length < expected) {
239
+ const padding = [];
240
+ for (let i = cells.length; i < expected; i++)
241
+ padding.push(buildCellBlock(''));
242
+ console.warn(`[${source}] row has ${cells.length} cells, expected ${expected} — padded with ${padding.length} empty`);
243
+ mutated = true;
244
+ return { ...child, content: [...child.content, ...padding] };
245
+ }
246
+ // cells.length > expected → truncate
247
+ const dropped = cells.length - expected;
248
+ console.warn(`[${source}] row has ${cells.length} cells, expected ${expected} — truncated ${dropped}`);
249
+ mutated = true;
250
+ // Keep non-cell entries (e.g. blanklines) and the first `expected` cells
251
+ let keepCells = expected;
252
+ const trimmed = [];
253
+ for (const c of child.content || []) {
254
+ if (c && c.type === 'block' && c.name === 'cell') {
255
+ if (keepCells > 0) {
256
+ trimmed.push(c);
257
+ keepCells--;
258
+ }
259
+ }
260
+ else {
261
+ trimmed.push(c);
262
+ }
263
+ }
264
+ return { ...child, content: trimmed };
265
+ });
266
+ return mutated ? { ...tableNode, content: newContent } : tableNode;
267
+ }
268
+ // Rule 3 — mixed separator detection (text-mode only). Inspects each line
269
+ // for visible separators (`|` / `+`) surrounded by whitespace; lines without
270
+ // any visible separator fall back to whitespace separation. Warns when more
271
+ // than one separator type is observed within a single table.
272
+ function detectMixedSeparators(lines) {
273
+ const seen = new Set();
274
+ for (const line of lines) {
275
+ if (!line || typeof line !== 'string')
276
+ continue;
277
+ if (/\s\|\s/.test(line))
278
+ seen.add('pipe');
279
+ else if (/\s\+\s/.test(line))
280
+ seen.add('plus');
281
+ else if (line.trim().length > 0)
282
+ seen.add('whitespace');
283
+ if (seen.size > 1)
284
+ break;
285
+ }
286
+ if (seen.size > 1) {
287
+ console.warn(`[table] mixed separator types detected: ${Array.from(seen).join(', ')} — recommend a single style`);
288
+ }
289
+ }
290
+ // Rule 4 — replace =table with =code block. Used when a referenced =data
291
+ // source has a non-CSV mime type: the spec mandates the source be rendered
292
+ // as a code block so the user can still see the underlying content.
293
+ function buildCodeFromDataBlock(tableNode, dataBlock) {
294
+ return {
295
+ type: 'block',
296
+ name: 'code',
297
+ margin: tableNode.margin || '',
298
+ content: Array.isArray(dataBlock.content) ? dataBlock.content : [],
299
+ config: Array.isArray(tableNode.config) ? tableNode.config : [],
300
+ };
301
+ }
15
302
  /**
16
303
  * Helpers section
17
304
  */
18
- // run cb in symbols pair
305
+ // Bit-mask helper used by the positional column extractor for multi-line
306
+ // rows. Compares two same-length digit strings character by character via
307
+ // `cb`, returning the joined result.
19
308
  const strbin = (str1, str2, cb) => {
20
309
  let res = [];
21
310
  for (let i = 0; str1.length > i; i++) {
@@ -23,62 +312,109 @@ const strbin = (str1, str2, cb) => {
23
312
  }
24
313
  return res.join('');
25
314
  };
26
- // Create mask for extract columns
315
+ // Build a unified column-position mask across a set of lines. Used as a
316
+ // fallback when a row spans multiple lines (continuation lines that align
317
+ // content by character position rather than by separator). Returns a binary
318
+ // string where `0` runs mark column ranges and `1` runs mark gaps.
27
319
  const makeMask = (lines, separators) => {
28
- // calculate template length
29
320
  const tmplLength = Math.max(...[...lines, ...separators].map(s => s.length));
30
- // make bin mask for each string
31
321
  const masks = lines.map(str => {
32
- /** make mask for each line
33
- ' The Shoveller | Eddie Stevens | King Arthur\'s singing shovel',
34
- '0000000011111111111110001111111111111000001111111111111111111111111111' ]
35
- then not(mask) ... then & masks
36
- */
37
- // enlarge string to tmplLength
38
- let tstr = str + ' '.repeat(tmplLength - str.length);
39
- let mask = [];
322
+ const tstr = str + ' '.repeat(tmplLength - str.length);
323
+ const mask = [];
40
324
  const re = /\s+[+|\s]\s/g;
41
325
  let match;
42
326
  while ((match = re.exec(tstr)) != null) {
43
327
  const tmpMask = '1'.repeat(match.index) + '0'.repeat(match[0].length);
44
328
  mask.push(tmpMask + '1'.repeat(tmplLength - tmpMask.length));
45
329
  }
46
- return mask.reduce((a, b) => {
47
- return strbin(a, b, (i1, i2) => i1 & i2);
48
- }, '1'.repeat(tmplLength));
330
+ return mask.reduce((a, b) => strbin(a, b, (i1, i2) => i1 & i2), '1'.repeat(tmplLength));
49
331
  });
50
- // make result mask
51
332
  const inverted = masks.map(m => strbin(m, '', i1 => (i1 == 0 ? 1 : 0)));
52
- const columnTemplate = inverted.reduce((a, b) => {
53
- return strbin(a, b, (i1, i2) => i1 & i2);
54
- }, '1'.repeat(tmplLength));
55
- return columnTemplate;
333
+ return inverted.reduce((a, b) => strbin(a, b, (i1, i2) => i1 & i2), '1'.repeat(tmplLength));
56
334
  };
335
+ // Apply a column-position template to a multi-line text block, extracting
336
+ // per-column substrings and aggregating across lines (continuation lines
337
+ // append to the cell at the same column position).
57
338
  const extractColumnsByTemplate = (text, template) => {
58
- const lines = flattenDeep(text
59
- .split(/\n/) // split each row by eol
60
- .filter(str => str.length > 0));
339
+ const lines = flattenDeep(text.split(/\n/).filter(s => s.length > 0));
61
340
  const cols = lines.map(line => {
62
341
  const re = /((1+|0+))/g;
63
- let columns = [];
342
+ const columns = [];
64
343
  let match;
65
344
  while ((match = re.exec(template)) != null) {
66
- if (match[0][0] == 1)
345
+ if (match[0][0] == '1')
67
346
  continue;
68
- const s = line.substring(match.index, match.index + match[0].length);
69
- columns.push(s);
347
+ columns.push(line.substring(match.index, match.index + match[0].length));
70
348
  }
71
349
  return columns;
72
350
  });
73
- let result = [];
74
- result = cols.reduce((a, b) => {
351
+ const result = [];
352
+ cols.reduce((a, b) => {
75
353
  for (let i = 0; i < b.length; i++) {
76
354
  a[i] = (a[i] === undefined ? '' : a[i]) + ' ' + b[i];
77
355
  }
78
356
  return a;
79
- }, []);
357
+ }, result);
80
358
  return result;
81
359
  };
360
+ const detectLineSeparator = (line) => {
361
+ if (/(?:^|\s)\|(?:\s|$)/.test(line))
362
+ return 'pipe';
363
+ if (/(?:^|\s)\+(?:\s|$)/.test(line))
364
+ return 'plus';
365
+ return 'whitespace';
366
+ };
367
+ const trimEdgeEmpty = (cells) => {
368
+ // A leading `|` produces an empty cell at index 0; a trailing `|` does the
369
+ // same at the end. Drop those edge artifacts. Empty cells in the middle of
370
+ // the row are preserved.
371
+ let start = 0;
372
+ let end = cells.length;
373
+ if (cells[start] === '')
374
+ start++;
375
+ if (end > start && cells[end - 1] === '')
376
+ end--;
377
+ return cells.slice(start, end);
378
+ };
379
+ const splitLineByPipe = (line) => trimEdgeEmpty(line.split(/\s*\|\s*/).map(c => c.trim()));
380
+ const splitLineByPlus = (line) => trimEdgeEmpty(line.split(/\s*\+\s*/).map(c => c.trim()));
381
+ const splitLineByWhitespace = (line) => line
382
+ .trim()
383
+ .split(/\s{2,}/)
384
+ .filter(c => c !== '');
385
+ const splitLineCells = (line) => {
386
+ const trimmed = line.trim();
387
+ if (trimmed === '')
388
+ return [];
389
+ const kind = detectLineSeparator(line);
390
+ if (kind === 'pipe')
391
+ return splitLineByPipe(line);
392
+ if (kind === 'plus')
393
+ return splitLineByPlus(line);
394
+ return splitLineByWhitespace(line);
395
+ };
396
+ // Convert a row's raw text (which may span multiple lines) to cell values.
397
+ // Multi-line rows: take the separator kind from the first non-blank line and
398
+ // apply it to every line of the row, then aggregate column-wise so that a
399
+ // continuation line (e.g. wrapped cell content) appends to the cell from the
400
+ // previous line in the same column.
401
+ const rowToCells = (rowValue) => {
402
+ const lines = rowValue.split(/\r?\n/).filter(l => l.trim() !== '');
403
+ if (lines.length === 0)
404
+ return [];
405
+ if (lines.length === 1)
406
+ return splitLineCells(lines[0]);
407
+ const kind = detectLineSeparator(lines[0]);
408
+ const splitFn = kind === 'pipe' ? splitLineByPipe : kind === 'plus' ? splitLineByPlus : splitLineByWhitespace;
409
+ const lineCells = lines.map(splitFn);
410
+ const maxCols = Math.max(...lineCells.map(c => c.length));
411
+ const merged = [];
412
+ for (let i = 0; i < maxCols; i++) {
413
+ const parts = lineCells.map(line => line[i] ?? '').filter(p => p !== '');
414
+ merged.push(parts.join(' '));
415
+ }
416
+ return merged;
417
+ };
82
418
  /**
83
419
  * Main transforms
84
420
  */
@@ -102,14 +438,45 @@ const isStructured = tableNode => Array.isArray(tableNode.content) &&
102
438
  exports.default = () => tree => {
103
439
  const transformer = (0, makeTransformer_1.default)({
104
440
  table: node => {
105
- // structured mode: transform row children (wrap implicit cells)
441
+ // CSV/data source reference (spec §1672):
442
+ // =table data:<key> → resolve =data block with :key<key>
443
+ // =table file:<path> → defer to host reader (not implemented here)
444
+ const ref = detectSourceReference(node);
445
+ if (ref && ref.scheme === 'data') {
446
+ const dataBlock = findDataBlockByKey(tree, ref.target);
447
+ if (!dataBlock) {
448
+ // Rule 4: source not found → empty table (still a =table block)
449
+ console.warn(`[table] no =data block found for data:${ref.target} — rendered as empty`);
450
+ return { ...node, content: [] };
451
+ }
452
+ const rawMime = (0, config_1.default)(dataBlock, {}).getFirstValue('mime-type');
453
+ const { type: mimeType, params: mimeParams } = parseMimeType(rawMime);
454
+ const isCsv = mimeType === 'text/csv';
455
+ const isTsv = mimeType === 'text/tab-separated-values';
456
+ if (isCsv || isTsv) {
457
+ const text = extractDataText(dataBlock);
458
+ const rows = isCsv ? parseCsv(text) : parseTsv(text);
459
+ if (rows.length === 0) {
460
+ console.warn(`[table] ${isCsv ? 'CSV' : 'TSV'} parse produced no rows for data:${ref.target} — rendered as empty`);
461
+ return { ...node, content: [] };
462
+ }
463
+ const hasHeader = mimeParams.header === 'present';
464
+ const filledNode = { ...node, content: csvToTableContent(rows, hasHeader) };
465
+ return normalizeCellCounts(filledNode, `table data:${ref.target}`);
466
+ }
467
+ // Rule 4: source not tabular → render as code block so content remains visible
468
+ console.warn(`[table] =data :key<${ref.target}> has non-tabular mime-type ${rawMime || '(none)'} — rendered as =code`);
469
+ return buildCodeFromDataBlock(node, dataBlock);
470
+ }
471
+ // structured mode: transform row children (wrap implicit cells), then
472
+ // apply Rule 2 cell count normalization.
106
473
  if (isStructured(node)) {
107
474
  const transformedContent = (node.content || []).map(c => {
108
475
  if (c && c.name === 'row')
109
476
  return wrapImplicitCells(c);
110
477
  return c;
111
478
  });
112
- return { ...node, content: transformedContent };
479
+ return normalizeCellCounts({ ...node, content: transformedContent }, 'table');
113
480
  }
114
481
  let rows = [];
115
482
  const collectValues = row => {
@@ -134,6 +501,8 @@ exports.default = () => tree => {
134
501
  // split each row into lines
135
502
  const lines = flattenDeep(rows.map(splitToLines));
136
503
  const separators = flattenDeep(seps.map(splitToLines));
504
+ // Rule 3: warn on mixed separator types within a single table
505
+ detectMixedSeparators(lines);
137
506
  // collect text rows
138
507
  let textRows = [];
139
508
  (0, makeTransformer_1.default)({
@@ -141,33 +510,48 @@ exports.default = () => tree => {
141
510
  textRows.push(row.value);
142
511
  },
143
512
  })(node);
144
- const columnTemplate = makeMask(lines, separators);
145
513
  const makeBlock = (name, content, extra = {}) => {
146
514
  return { ...extra, name, type: 'block', content: Array.isArray(content) ? content : [content] };
147
515
  };
148
516
  const makeRow = cells => makeBlock('row', cells);
149
517
  const makeHeaderRow = cells => makeBlock('row', cells, { config: [{ name: 'header', value: true, type: 'boolean' }] });
150
518
  const makeCell = text => makeBlock('cell', { type: 'text', value: text });
151
- // make columns
519
+ // Routing: per-line separator detection (Rule 1) is used only when a
520
+ // line with a visible separator (`|` or `+`) coexists with one that
521
+ // has only whitespace separation — the Scenario 2 case where the
522
+ // legacy positional mask collapses columns. Tables with a uniform
523
+ // separator, or with both visible kinds (pipe + plus) handled by the
524
+ // shared mask, fall back to the legacy positional template, which
525
+ // preserves continuation-line alignment in multi-line rows and keeps
526
+ // byte-for-byte AST/HTML output stable.
527
+ const columnTemplate = makeMask(lines, separators);
528
+ const seenSeparatorKinds = new Set(lines.map(detectLineSeparator));
529
+ const hasVisible = seenSeparatorKinds.has('pipe') || seenSeparatorKinds.has('plus');
530
+ const hasWhitespace = seenSeparatorKinds.has('whitespace');
531
+ const useMixedSplitting = hasVisible && hasWhitespace;
532
+ const splitToCells = (rowValue) => {
533
+ if (useMixedSplitting) {
534
+ const rowLines = rowValue.split(/\r?\n/).filter(l => l.trim() !== '');
535
+ if (rowLines.length <= 1)
536
+ return rowToCells(rowValue);
537
+ }
538
+ return extractColumnsByTemplate(rowValue, columnTemplate);
539
+ };
152
540
  const res = (0, makeTransformer_1.default)({
153
541
  'row:text': row => {
154
542
  if (textRows.length == 1) {
155
- // split each text row into lines
543
+ // No separator blocks: each line of the only text row becomes its own row
156
544
  const textRowsLines = flattenDeep([row.value].map(splitToLines));
157
- return textRowsLines.map(rowValue => {
158
- const cols = extractColumnsByTemplate(rowValue, columnTemplate);
159
- return makeRow(cols.map(makeCell));
160
- });
545
+ if (useMixedSplitting) {
546
+ return textRowsLines.map(line => makeRow(splitLineCells(line).map(makeCell)));
547
+ }
548
+ return textRowsLines.map(line => makeRow(extractColumnsByTemplate(line, columnTemplate).map(makeCell)));
161
549
  }
162
- const cols = extractColumnsByTemplate(row.value, columnTemplate);
163
- return makeRow(cols.map(makeCell));
164
- },
165
- 'head:text': head => {
166
- const cols = extractColumnsByTemplate(head.value, columnTemplate);
167
- return makeHeaderRow(cols.map(makeCell));
550
+ return makeRow(splitToCells(row.value).map(makeCell));
168
551
  },
552
+ 'head:text': head => makeHeaderRow(splitToCells(head.value).map(makeCell)),
169
553
  })(node);
170
- return res;
554
+ return normalizeCellCounts(res, 'table');
171
555
  },
172
556
  });
173
557
  return transformer(tree, {});
@@ -0,0 +1,18 @@
1
+ import { PodliteDocument, PodNode } from './index';
2
+ /**
3
+ * Minimal corpus item that the selector engine needs. Concrete consumers
4
+ * (publisher's `publishRecord`, editor preview, etc.) supply richer
5
+ * objects; only `file` and `node` are read here.
6
+ */
7
+ export declare type SelectorDoc = {
8
+ file: string;
9
+ node: PodNode | PodliteDocument;
10
+ };
11
+ export declare type ParsedSelector = {
12
+ scheme?: string;
13
+ document?: string;
14
+ anchor?: string;
15
+ blockFilters: string[];
16
+ };
17
+ export declare const parseSelector: (selector: string) => ParsedSelector | undefined;
18
+ export declare const runSelector: <T extends SelectorDoc>(selector: string, docs: T[]) => PodNode[] | T[];