@podlite/schema 0.0.37 → 0.0.39

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.
Files changed (44) hide show
  1. package/CHANGELOG.podlite +26 -0
  2. package/README.md +61 -4
  3. package/esm/blocks-helpers.d.ts +2 -2
  4. package/esm/blocks-helpers.js +11 -4
  5. package/esm/blocks-helpers.js.map +1 -1
  6. package/esm/exportHtml.js +57 -6
  7. package/esm/exportHtml.js.map +1 -1
  8. package/esm/exportMarkdown.js +52 -6
  9. package/esm/exportMarkdown.js.map +1 -1
  10. package/esm/grammar.js +442 -215
  11. package/esm/grammar.js.map +1 -1
  12. package/esm/grammarfc.js +1819 -1459
  13. package/esm/grammarfc.js.map +1 -1
  14. package/esm/helpers/config.d.ts +1 -0
  15. package/esm/helpers/config.js +8 -0
  16. package/esm/helpers/config.js.map +1 -1
  17. package/esm/index.d.ts +2 -0
  18. package/esm/index.js +1 -0
  19. package/esm/index.js.map +1 -1
  20. package/esm/plugin-tables.d.ts +0 -3
  21. package/esm/plugin-tables.js +425 -45
  22. package/esm/plugin-tables.js.map +1 -1
  23. package/esm/selectors.d.ts +18 -0
  24. package/esm/selectors.js +158 -0
  25. package/esm/selectors.js.map +1 -0
  26. package/esm/types.d.ts +9 -7
  27. package/lib/blocks-helpers.d.ts +2 -2
  28. package/lib/blocks-helpers.js +13 -6
  29. package/lib/exportHtml.js +57 -6
  30. package/lib/exportMarkdown.js +52 -6
  31. package/lib/grammar.js +442 -215
  32. package/lib/grammarfc.js +1819 -1459
  33. package/lib/helpers/config.d.ts +1 -0
  34. package/lib/helpers/config.js +8 -0
  35. package/lib/index.d.ts +2 -0
  36. package/lib/index.js +4 -1
  37. package/lib/plugin-tables.d.ts +0 -3
  38. package/lib/plugin-tables.js +425 -45
  39. package/lib/selectors.d.ts +18 -0
  40. package/lib/selectors.js +163 -0
  41. package/lib/types.d.ts +9 -7
  42. package/package.json +2 -2
  43. package/schema/AstTree.json +6 -3
  44. package/schema/PodliteDocument.json +6 -3
@@ -4,6 +4,7 @@ export declare type Context = {
4
4
  export interface Attr {
5
5
  getAllValues: (name: any) => any;
6
6
  getFirstValue: (name: any) => any;
7
+ getMapValue: (name: string) => Record<string, string | number | boolean> | undefined;
7
8
  asHash: () => {};
8
9
  (): {};
9
10
  exists(name: string): boolean;
@@ -43,6 +43,14 @@ const makeAttrs = (node, ctx = {}) => {
43
43
  resfn.getFirstValue = name => {
44
44
  return resfn.exists(name) ? resfn.getAllValues(name)[0] : undefined;
45
45
  };
46
+ /**
47
+ * return first map-typed value (from `:attr{ k=>v, ... }` syntax) or
48
+ * undefined when the attribute is absent or not a map value
49
+ */
50
+ resfn.getMapValue = name => {
51
+ const first = resfn.getFirstValue(name);
52
+ return first && typeof first === 'object' && !Array.isArray(first) ? first : undefined;
53
+ };
46
54
  /**
47
55
  * return key: val
48
56
  *
package/lib/index.d.ts CHANGED
@@ -78,5 +78,7 @@ export { parse as parse };
78
78
  export { default as toHtml } from './exportHtml';
79
79
  export { default as toMarkdown } from './exportMarkdown';
80
80
  export { default as Writer } from './writer';
81
+ export { parseSelector, runSelector } from './selectors';
82
+ export type { SelectorDoc, ParsedSelector } from './selectors';
81
83
  declare const VERSION: any;
82
84
  export { VERSION as version };
package/lib/index.js CHANGED
@@ -25,7 +25,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
25
25
  return (mod && mod.__esModule) ? mod : { "default": mod };
26
26
  };
27
27
  Object.defineProperty(exports, "__esModule", { value: true });
28
- exports.version = exports.Writer = exports.toMarkdown = exports.toHtml = exports.parse = exports.toTree = exports.isValidateError = exports.validateAst = exports.validateAstTree = exports.validatePodliteAst = exports.getPodContentFromNode = exports.getTextContentFromNode = exports.toAst = exports.frozenIds = exports.cleanIds = exports.podlitePluggable = exports.toAnyRules = exports.pluginCleanLocation = exports.makeAttrs = exports.toAny = exports.isSemanticBlock = exports.isNamedBlock = exports.makeTransformer = exports.makeInterator = void 0;
28
+ exports.version = exports.runSelector = exports.parseSelector = exports.Writer = exports.toMarkdown = exports.toHtml = exports.parse = exports.toTree = exports.isValidateError = exports.validateAst = exports.validateAstTree = exports.validatePodliteAst = exports.getPodContentFromNode = exports.getTextContentFromNode = exports.toAst = exports.frozenIds = exports.cleanIds = exports.podlitePluggable = exports.toAnyRules = exports.pluginCleanLocation = exports.makeAttrs = exports.toAny = exports.isSemanticBlock = exports.isNamedBlock = exports.makeTransformer = exports.makeInterator = void 0;
29
29
  const ajv_1 = __importDefault(require("ajv"));
30
30
  const pointer = __importStar(require("json-pointer"));
31
31
  const jsonShemes = __importStar(require("../schema"));
@@ -189,6 +189,9 @@ var exportMarkdown_1 = require("./exportMarkdown");
189
189
  Object.defineProperty(exports, "toMarkdown", { enumerable: true, get: function () { return __importDefault(exportMarkdown_1).default; } });
190
190
  var writer_1 = require("./writer");
191
191
  Object.defineProperty(exports, "Writer", { enumerable: true, get: function () { return __importDefault(writer_1).default; } });
192
+ var selectors_1 = require("./selectors");
193
+ Object.defineProperty(exports, "parseSelector", { enumerable: true, get: function () { return selectors_1.parseSelector; } });
194
+ Object.defineProperty(exports, "runSelector", { enumerable: true, get: function () { return selectors_1.runSelector; } });
192
195
  // Cannot be `import` as it's not under TS root dir
193
196
  // https://stackoverflow.com/questions/51070138/how-to-import-package-json-into-typescript-file-without-including-it-in-the-comp
194
197
  const { version: VERSION } = require('../package.json');
@@ -1,5 +1,2 @@
1
1
  declare const _default: () => (tree: any) => any;
2
- /**
3
- * Main transforms
4
- */
5
2
  export default _default;
@@ -9,13 +9,271 @@ 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
+ // Locate the first `=data` block with a matching `:key` attribute anywhere
85
+ // in the document tree.
86
+ function findDataBlockByKey(tree, key) {
87
+ let found = null;
88
+ const walk = node => {
89
+ if (found)
90
+ return;
91
+ if (Array.isArray(node)) {
92
+ node.forEach(walk);
93
+ return;
94
+ }
95
+ if (!node || typeof node !== 'object')
96
+ return;
97
+ if (node.type === 'block' && node.name === 'data') {
98
+ const attrs = (0, config_1.default)(node, {});
99
+ if (attrs.getFirstValue('key') === key) {
100
+ found = node;
101
+ return;
102
+ }
103
+ }
104
+ if (Array.isArray(node.content))
105
+ node.content.forEach(walk);
106
+ };
107
+ walk(tree);
108
+ return found;
109
+ }
110
+ function extractDataText(dataNode) {
111
+ if (!dataNode || !Array.isArray(dataNode.content))
112
+ return '';
113
+ const verbatim = dataNode.content.find(c => c && c.type === 'verbatim');
114
+ return verbatim && typeof verbatim.value === 'string' ? verbatim.value : '';
115
+ }
116
+ // Detect whether the =table body is a single-line source reference like
117
+ // `data:key` or `file:path` (spec §1672). Returns the parsed reference or
118
+ // null if the body is ordinary table content.
119
+ function detectSourceReference(tableNode) {
120
+ const texts = [];
121
+ const walker = (0, makeTransformer_1.default)({
122
+ 'row:text': r => {
123
+ texts.push(r.value);
124
+ return r;
125
+ },
126
+ 'head:text': h => {
127
+ texts.push(h.value);
128
+ return h;
129
+ },
130
+ });
131
+ walker(tableNode, {});
132
+ const joined = texts.join('\n').trim();
133
+ const lines = joined
134
+ .split(/\r?\n/)
135
+ .map(l => l.trim())
136
+ .filter(l => l.length > 0);
137
+ if (lines.length !== 1)
138
+ return null;
139
+ const m = lines[0].match(/^(data|file):(\S+)$/);
140
+ if (!m)
141
+ return null;
142
+ return { scheme: m[1], target: m[2] };
143
+ }
144
+ function buildCellBlock(text) {
145
+ return {
146
+ name: 'cell',
147
+ type: 'block',
148
+ margin: '',
149
+ content: [{ type: 'text', value: text }],
150
+ };
151
+ }
152
+ function buildRowBlock(cells, isHeader) {
153
+ const block = {
154
+ name: 'row',
155
+ type: 'block',
156
+ margin: '',
157
+ content: cells,
158
+ };
159
+ if (isHeader) {
160
+ block.config = [{ name: 'header', value: true, type: 'boolean' }];
161
+ }
162
+ return block;
163
+ }
164
+ // Convert parsed CSV/TSV rows to plain `=row`/`=cell` blocks. The spec is
165
+ // silent on how to mark a header row in `=table data:<key>` references, so
166
+ // no row receives `:header` here. Authors who need a header row can use a
167
+ // structured table with explicit `=begin row :header`, or a Markdown GFM
168
+ // table with a separator line.
169
+ function csvToTableContent(csvRows) {
170
+ return csvRows.map(row => {
171
+ const cells = row.map(v => buildCellBlock(v.trim()));
172
+ return buildRowBlock(cells, false);
173
+ });
174
+ }
175
+ // ─── Error recovery (design notes Rules 2-4) ───────────────────────────────
176
+ // Rule 2 — table-level cell count validation. Pad short rows with empty
177
+ // cells; truncate long rows. Emit a warning whenever a row is changed.
178
+ // Expected count is taken from the `:header` row if present, otherwise from
179
+ // the row with the maximum cell count.
180
+ //
181
+ // Skipped when any cell uses `:colspan` or `:rowspan`: a spanning cell
182
+ // occupies multiple columns, so naive cell counting would misreport row
183
+ // width and drop legitimate spanned cells.
184
+ function normalizeCellCounts(tableNode, source = 'table') {
185
+ if (!tableNode || !Array.isArray(tableNode.content))
186
+ return tableNode;
187
+ const rows = tableNode.content.filter(c => c && c.type === 'block' && c.name === 'row');
188
+ if (rows.length === 0)
189
+ return tableNode;
190
+ const cellsOf = row => (Array.isArray(row.content) ? row.content.filter(c => c && c.name === 'cell') : []);
191
+ const isHeaderRow = row => Array.isArray(row.config) && row.config.some(a => a.name === 'header' && a.value === true);
192
+ const cellHasSpan = cell => Array.isArray(cell.config) && cell.config.some(a => a.name === 'colspan' || a.name === 'rowspan');
193
+ const rowHasSpan = row => cellsOf(row).some(cellHasSpan);
194
+ if (rows.some(rowHasSpan))
195
+ return tableNode;
196
+ const headerRow = rows.find(isHeaderRow);
197
+ const expected = headerRow ? cellsOf(headerRow).length : Math.max(...rows.map(r => cellsOf(r).length));
198
+ if (expected === 0)
199
+ return tableNode;
200
+ let mutated = false;
201
+ const newContent = tableNode.content.map(child => {
202
+ if (!child || child.type !== 'block' || child.name !== 'row')
203
+ return child;
204
+ const cells = cellsOf(child);
205
+ if (cells.length === expected)
206
+ return child;
207
+ if (cells.length < expected) {
208
+ const padding = [];
209
+ for (let i = cells.length; i < expected; i++)
210
+ padding.push(buildCellBlock(''));
211
+ console.warn(`[${source}] row has ${cells.length} cells, expected ${expected} — padded with ${padding.length} empty`);
212
+ mutated = true;
213
+ return { ...child, content: [...child.content, ...padding] };
214
+ }
215
+ // cells.length > expected → truncate
216
+ const dropped = cells.length - expected;
217
+ console.warn(`[${source}] row has ${cells.length} cells, expected ${expected} — truncated ${dropped}`);
218
+ mutated = true;
219
+ // Keep non-cell entries (e.g. blanklines) and the first `expected` cells
220
+ let keepCells = expected;
221
+ const trimmed = [];
222
+ for (const c of child.content || []) {
223
+ if (c && c.type === 'block' && c.name === 'cell') {
224
+ if (keepCells > 0) {
225
+ trimmed.push(c);
226
+ keepCells--;
227
+ }
228
+ }
229
+ else {
230
+ trimmed.push(c);
231
+ }
232
+ }
233
+ return { ...child, content: trimmed };
234
+ });
235
+ return mutated ? { ...tableNode, content: newContent } : tableNode;
236
+ }
237
+ // Rule 3 — mixed separator detection (text-mode only). Inspects each line
238
+ // for visible separators (`|` / `+`) surrounded by whitespace; lines without
239
+ // any visible separator fall back to whitespace separation. Warns when more
240
+ // than one separator type is observed within a single table.
241
+ function detectMixedSeparators(lines) {
242
+ const seen = new Set();
243
+ for (const line of lines) {
244
+ if (!line || typeof line !== 'string')
245
+ continue;
246
+ if (/\s\|\s/.test(line))
247
+ seen.add('pipe');
248
+ else if (/\s\+\s/.test(line))
249
+ seen.add('plus');
250
+ else if (line.trim().length > 0)
251
+ seen.add('whitespace');
252
+ if (seen.size > 1)
253
+ break;
254
+ }
255
+ if (seen.size > 1) {
256
+ console.warn(`[table] mixed separator types detected: ${Array.from(seen).join(', ')} — recommend a single style`);
257
+ }
258
+ }
259
+ // Rule 4 — replace =table with =code block. Used when a referenced =data
260
+ // source has a non-CSV mime type: the spec mandates the source be rendered
261
+ // as a code block so the user can still see the underlying content.
262
+ function buildCodeFromDataBlock(tableNode, dataBlock) {
263
+ return {
264
+ type: 'block',
265
+ name: 'code',
266
+ margin: tableNode.margin || '',
267
+ content: Array.isArray(dataBlock.content) ? dataBlock.content : [],
268
+ config: Array.isArray(tableNode.config) ? tableNode.config : [],
269
+ };
270
+ }
15
271
  /**
16
272
  * Helpers section
17
273
  */
18
- // run cb in symbols pair
274
+ // Bit-mask helper used by the positional column extractor for multi-line
275
+ // rows. Compares two same-length digit strings character by character via
276
+ // `cb`, returning the joined result.
19
277
  const strbin = (str1, str2, cb) => {
20
278
  let res = [];
21
279
  for (let i = 0; str1.length > i; i++) {
@@ -23,68 +281,170 @@ const strbin = (str1, str2, cb) => {
23
281
  }
24
282
  return res.join('');
25
283
  };
26
- // Create mask for extract columns
284
+ // Build a unified column-position mask across a set of lines. Used as a
285
+ // fallback when a row spans multiple lines (continuation lines that align
286
+ // content by character position rather than by separator). Returns a binary
287
+ // string where `0` runs mark column ranges and `1` runs mark gaps.
27
288
  const makeMask = (lines, separators) => {
28
- // calculate template length
29
289
  const tmplLength = Math.max(...[...lines, ...separators].map(s => s.length));
30
- // make bin mask for each string
31
290
  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 = [];
291
+ const tstr = str + ' '.repeat(tmplLength - str.length);
292
+ const mask = [];
40
293
  const re = /\s+[+|\s]\s/g;
41
294
  let match;
42
295
  while ((match = re.exec(tstr)) != null) {
43
296
  const tmpMask = '1'.repeat(match.index) + '0'.repeat(match[0].length);
44
297
  mask.push(tmpMask + '1'.repeat(tmplLength - tmpMask.length));
45
298
  }
46
- return mask.reduce((a, b) => {
47
- return strbin(a, b, (i1, i2) => i1 & i2);
48
- }, '1'.repeat(tmplLength));
299
+ return mask.reduce((a, b) => strbin(a, b, (i1, i2) => i1 & i2), '1'.repeat(tmplLength));
49
300
  });
50
- // make result mask
51
301
  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;
302
+ return inverted.reduce((a, b) => strbin(a, b, (i1, i2) => i1 & i2), '1'.repeat(tmplLength));
56
303
  };
304
+ // Apply a column-position template to a multi-line text block, extracting
305
+ // per-column substrings and aggregating across lines (continuation lines
306
+ // append to the cell at the same column position).
57
307
  const extractColumnsByTemplate = (text, template) => {
58
- const lines = flattenDeep(text
59
- .split(/\n/) // split each row by eol
60
- .filter(str => str.length > 0));
308
+ const lines = flattenDeep(text.split(/\n/).filter(s => s.length > 0));
61
309
  const cols = lines.map(line => {
62
310
  const re = /((1+|0+))/g;
63
- let columns = [];
311
+ const columns = [];
64
312
  let match;
65
313
  while ((match = re.exec(template)) != null) {
66
- if (match[0][0] == 1)
314
+ if (match[0][0] == '1')
67
315
  continue;
68
- const s = line.substring(match.index, match.index + match[0].length);
69
- columns.push(s);
316
+ columns.push(line.substring(match.index, match.index + match[0].length));
70
317
  }
71
318
  return columns;
72
319
  });
73
- let result = [];
74
- result = cols.reduce((a, b) => {
320
+ const result = [];
321
+ cols.reduce((a, b) => {
75
322
  for (let i = 0; i < b.length; i++) {
76
323
  a[i] = (a[i] === undefined ? '' : a[i]) + ' ' + b[i];
77
324
  }
78
325
  return a;
79
- }, []);
326
+ }, result);
80
327
  return result;
81
328
  };
329
+ const detectLineSeparator = (line) => {
330
+ if (/(?:^|\s)\|(?:\s|$)/.test(line))
331
+ return 'pipe';
332
+ if (/(?:^|\s)\+(?:\s|$)/.test(line))
333
+ return 'plus';
334
+ return 'whitespace';
335
+ };
336
+ const trimEdgeEmpty = (cells) => {
337
+ // A leading `|` produces an empty cell at index 0; a trailing `|` does the
338
+ // same at the end. Drop those edge artifacts. Empty cells in the middle of
339
+ // the row are preserved.
340
+ let start = 0;
341
+ let end = cells.length;
342
+ if (cells[start] === '')
343
+ start++;
344
+ if (end > start && cells[end - 1] === '')
345
+ end--;
346
+ return cells.slice(start, end);
347
+ };
348
+ const splitLineByPipe = (line) => trimEdgeEmpty(line.split(/\s*\|\s*/).map(c => c.trim()));
349
+ const splitLineByPlus = (line) => trimEdgeEmpty(line.split(/\s*\+\s*/).map(c => c.trim()));
350
+ const splitLineByWhitespace = (line) => line
351
+ .trim()
352
+ .split(/\s{2,}/)
353
+ .filter(c => c !== '');
354
+ const splitLineCells = (line) => {
355
+ const trimmed = line.trim();
356
+ if (trimmed === '')
357
+ return [];
358
+ const kind = detectLineSeparator(line);
359
+ if (kind === 'pipe')
360
+ return splitLineByPipe(line);
361
+ if (kind === 'plus')
362
+ return splitLineByPlus(line);
363
+ return splitLineByWhitespace(line);
364
+ };
365
+ // Convert a row's raw text (which may span multiple lines) to cell values.
366
+ // Multi-line rows: take the separator kind from the first non-blank line and
367
+ // apply it to every line of the row, then aggregate column-wise so that a
368
+ // continuation line (e.g. wrapped cell content) appends to the cell from the
369
+ // previous line in the same column.
370
+ const rowToCells = (rowValue) => {
371
+ const lines = rowValue.split(/\r?\n/).filter(l => l.trim() !== '');
372
+ if (lines.length === 0)
373
+ return [];
374
+ if (lines.length === 1)
375
+ return splitLineCells(lines[0]);
376
+ const kind = detectLineSeparator(lines[0]);
377
+ const splitFn = kind === 'pipe' ? splitLineByPipe : kind === 'plus' ? splitLineByPlus : splitLineByWhitespace;
378
+ const lineCells = lines.map(splitFn);
379
+ const maxCols = Math.max(...lineCells.map(c => c.length));
380
+ const merged = [];
381
+ for (let i = 0; i < maxCols; i++) {
382
+ const parts = lineCells.map(line => line[i] ?? '').filter(p => p !== '');
383
+ merged.push(parts.join(' '));
384
+ }
385
+ return merged;
386
+ };
82
387
  /**
83
388
  * Main transforms
84
389
  */
390
+ const wrapImplicitCells = rowNode => {
391
+ if (!Array.isArray(rowNode.content) || rowNode.content.length === 0)
392
+ return rowNode;
393
+ const hasNonCell = rowNode.content.some(c => c && c.type === 'block' && c.name !== 'cell' && c.name !== 'blankline');
394
+ if (!hasNonCell)
395
+ return rowNode;
396
+ const wrapped = rowNode.content.map(c => {
397
+ if (!c || c.type !== 'block')
398
+ return c;
399
+ if (c.name === 'cell' || c.name === 'blankline')
400
+ return c;
401
+ return { type: 'block', name: 'cell', content: [c], margin: c.margin || '' };
402
+ });
403
+ return { ...rowNode, content: wrapped };
404
+ };
405
+ const isStructured = tableNode => Array.isArray(tableNode.content) &&
406
+ tableNode.content.some(c => c && c.type === 'block' && (c.name === 'row' || c.name === 'cell'));
85
407
  exports.default = () => tree => {
86
408
  const transformer = (0, makeTransformer_1.default)({
87
409
  table: node => {
410
+ // CSV/data source reference (spec §1672):
411
+ // =table data:<key> → resolve =data block with :key<key>
412
+ // =table file:<path> → defer to host reader (not implemented here)
413
+ const ref = detectSourceReference(node);
414
+ if (ref && ref.scheme === 'data') {
415
+ const dataBlock = findDataBlockByKey(tree, ref.target);
416
+ if (!dataBlock) {
417
+ // Rule 4: source not found → empty table (still a =table block)
418
+ console.warn(`[table] no =data block found for data:${ref.target} — rendered as empty`);
419
+ return { ...node, content: [] };
420
+ }
421
+ const mimeType = (0, config_1.default)(dataBlock, {}).getFirstValue('mime-type');
422
+ const isCsv = mimeType === 'text/csv';
423
+ const isTsv = mimeType === 'text/tab-separated-values';
424
+ if (isCsv || isTsv) {
425
+ const text = extractDataText(dataBlock);
426
+ const rows = isCsv ? parseCsv(text) : parseTsv(text);
427
+ if (rows.length === 0) {
428
+ console.warn(`[table] ${isCsv ? 'CSV' : 'TSV'} parse produced no rows for data:${ref.target} — rendered as empty`);
429
+ return { ...node, content: [] };
430
+ }
431
+ const filledNode = { ...node, content: csvToTableContent(rows) };
432
+ return normalizeCellCounts(filledNode, `table data:${ref.target}`);
433
+ }
434
+ // Rule 4: source not tabular → render as code block so content remains visible
435
+ console.warn(`[table] =data :key<${ref.target}> has non-tabular mime-type ${mimeType || '(none)'} — rendered as =code`);
436
+ return buildCodeFromDataBlock(node, dataBlock);
437
+ }
438
+ // structured mode: transform row children (wrap implicit cells), then
439
+ // apply Rule 2 cell count normalization.
440
+ if (isStructured(node)) {
441
+ const transformedContent = (node.content || []).map(c => {
442
+ if (c && c.name === 'row')
443
+ return wrapImplicitCells(c);
444
+ return c;
445
+ });
446
+ return normalizeCellCounts({ ...node, content: transformedContent }, 'table');
447
+ }
88
448
  let rows = [];
89
449
  const collectValues = row => {
90
450
  rows.push(row.value);
@@ -108,6 +468,8 @@ exports.default = () => tree => {
108
468
  // split each row into lines
109
469
  const lines = flattenDeep(rows.map(splitToLines));
110
470
  const separators = flattenDeep(seps.map(splitToLines));
471
+ // Rule 3: warn on mixed separator types within a single table
472
+ detectMixedSeparators(lines);
111
473
  // collect text rows
112
474
  let textRows = [];
113
475
  (0, makeTransformer_1.default)({
@@ -115,30 +477,48 @@ exports.default = () => tree => {
115
477
  textRows.push(row.value);
116
478
  },
117
479
  })(node);
480
+ const makeBlock = (name, content, extra = {}) => {
481
+ return { ...extra, name, type: 'block', content: Array.isArray(content) ? content : [content] };
482
+ };
483
+ const makeRow = cells => makeBlock('row', cells);
484
+ const makeHeaderRow = cells => makeBlock('row', cells, { config: [{ name: 'header', value: true, type: 'boolean' }] });
485
+ const makeCell = text => makeBlock('cell', { type: 'text', value: text });
486
+ // Routing: per-line separator detection (Rule 1) is used only when a
487
+ // line with a visible separator (`|` or `+`) coexists with one that
488
+ // has only whitespace separation — the Scenario 2 case where the
489
+ // legacy positional mask collapses columns. Tables with a uniform
490
+ // separator, or with both visible kinds (pipe + plus) handled by the
491
+ // shared mask, fall back to the legacy positional template, which
492
+ // preserves continuation-line alignment in multi-line rows and keeps
493
+ // byte-for-byte AST/HTML output stable.
118
494
  const columnTemplate = makeMask(lines, separators);
119
- const makeBlock = (name, content, ...attr) => {
120
- return { ...attr, name, type: 'block', content: Array.isArray(content) ? content : [content] };
495
+ const seenSeparatorKinds = new Set(lines.map(detectLineSeparator));
496
+ const hasVisible = seenSeparatorKinds.has('pipe') || seenSeparatorKinds.has('plus');
497
+ const hasWhitespace = seenSeparatorKinds.has('whitespace');
498
+ const useMixedSplitting = hasVisible && hasWhitespace;
499
+ const splitToCells = (rowValue) => {
500
+ if (useMixedSplitting) {
501
+ const rowLines = rowValue.split(/\r?\n/).filter(l => l.trim() !== '');
502
+ if (rowLines.length <= 1)
503
+ return rowToCells(rowValue);
504
+ }
505
+ return extractColumnsByTemplate(rowValue, columnTemplate);
121
506
  };
122
- // make columns
123
507
  const res = (0, makeTransformer_1.default)({
124
508
  'row:text': row => {
125
509
  if (textRows.length == 1) {
126
- // split each text row into lines
510
+ // No separator blocks: each line of the only text row becomes its own row
127
511
  const textRowsLines = flattenDeep([row.value].map(splitToLines));
128
- return textRowsLines.map(rowValue => {
129
- const res = extractColumnsByTemplate(rowValue, columnTemplate);
130
- return makeBlock('table_row', res.map(col => makeBlock('table_cell', { type: 'text', value: col })));
131
- });
512
+ if (useMixedSplitting) {
513
+ return textRowsLines.map(line => makeRow(splitLineCells(line).map(makeCell)));
514
+ }
515
+ return textRowsLines.map(line => makeRow(extractColumnsByTemplate(line, columnTemplate).map(makeCell)));
132
516
  }
133
- const res = extractColumnsByTemplate(row.value, columnTemplate);
134
- return makeBlock('table_row', res.map(col => makeBlock('table_cell', { type: 'text', value: col })));
135
- },
136
- 'head:text': head => {
137
- const res = extractColumnsByTemplate(head.value, columnTemplate);
138
- return makeBlock('table_head', res.map(col => makeBlock('table_cell', { type: 'text', value: col })));
517
+ return makeRow(splitToCells(row.value).map(makeCell));
139
518
  },
519
+ 'head:text': head => makeHeaderRow(splitToCells(head.value).map(makeCell)),
140
520
  })(node);
141
- return res;
521
+ return normalizeCellCounts(res, 'table');
142
522
  },
143
523
  });
144
524
  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[];