tplm-lang 0.1.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.
Files changed (36) hide show
  1. package/README.md +357 -0
  2. package/dist/compiler/grid-spec-builder.d.ts +30 -0
  3. package/dist/compiler/grid-spec-builder.js +1836 -0
  4. package/dist/compiler/index.d.ts +11 -0
  5. package/dist/compiler/index.js +13 -0
  6. package/dist/compiler/malloy-generator.d.ts +36 -0
  7. package/dist/compiler/malloy-generator.js +141 -0
  8. package/dist/compiler/multi-query-utils.d.ts +42 -0
  9. package/dist/compiler/multi-query-utils.js +185 -0
  10. package/dist/compiler/query-plan-generator.d.ts +77 -0
  11. package/dist/compiler/query-plan-generator.js +1456 -0
  12. package/dist/compiler/table-spec-builder.d.ts +11 -0
  13. package/dist/compiler/table-spec-builder.js +588 -0
  14. package/dist/compiler/table-spec.d.ts +434 -0
  15. package/dist/compiler/table-spec.js +274 -0
  16. package/dist/executor/index.d.ts +71 -0
  17. package/dist/executor/index.js +232 -0
  18. package/dist/index.d.ts +214 -0
  19. package/dist/index.js +220 -0
  20. package/dist/parser/ast.d.ts +253 -0
  21. package/dist/parser/ast.js +164 -0
  22. package/dist/parser/chevrotain-parser.d.ts +118 -0
  23. package/dist/parser/chevrotain-parser.js +1266 -0
  24. package/dist/parser/index.d.ts +30 -0
  25. package/dist/parser/index.js +36 -0
  26. package/dist/parser/parser.d.ts +4 -0
  27. package/dist/parser/parser.js +4354 -0
  28. package/dist/parser/prettifier.d.ts +14 -0
  29. package/dist/parser/prettifier.js +380 -0
  30. package/dist/renderer/grid-renderer.d.ts +19 -0
  31. package/dist/renderer/grid-renderer.js +541 -0
  32. package/dist/renderer/index.d.ts +4 -0
  33. package/dist/renderer/index.js +4 -0
  34. package/package.json +67 -0
  35. package/packages/parser/tpl.pegjs +568 -0
  36. package/packages/renderer/tpl-table.css +182 -0
@@ -0,0 +1,541 @@
1
+ /**
2
+ * Grid Renderer
3
+ *
4
+ * Renders a GridSpec directly to HTML.
5
+ *
6
+ * Key simplification: GridSpec already has the complete header hierarchy
7
+ * with pre-computed spans, so we don't need to reconstruct structure.
8
+ */
9
+ /**
10
+ * Render a GridSpec to HTML.
11
+ */
12
+ export function renderGridToHTML(grid, options = {}) {
13
+ const { tableClass = 'tpl-table', showDimensionLabels = true, } = options;
14
+ const lines = [];
15
+ lines.push(`<table class="${tableClass}">`);
16
+ // Render column headers
17
+ renderColumnHeaders(grid, lines, showDimensionLabels);
18
+ // Render body (row headers + data cells)
19
+ renderBody(grid, lines, showDimensionLabels);
20
+ lines.push('</table>');
21
+ return lines.join('\n');
22
+ }
23
+ // ---
24
+ // COLUMN HEADERS
25
+ // ---
26
+ /**
27
+ * Get the maximum depth of header nodes.
28
+ */
29
+ function getMaxDepth(nodes) {
30
+ let maxDepth = 0;
31
+ for (const node of nodes) {
32
+ maxDepth = Math.max(maxDepth, node.depth);
33
+ if (node.children) {
34
+ maxDepth = Math.max(maxDepth, getMaxDepth(node.children));
35
+ }
36
+ }
37
+ return maxDepth;
38
+ }
39
+ /**
40
+ * Collect all nodes at a specific depth level.
41
+ */
42
+ function getNodesAtDepth(nodes, targetDepth) {
43
+ const result = [];
44
+ function collect(node) {
45
+ if (node.depth === targetDepth) {
46
+ result.push(node);
47
+ }
48
+ if (node.children) {
49
+ for (const child of node.children) {
50
+ collect(child);
51
+ }
52
+ }
53
+ }
54
+ for (const node of nodes) {
55
+ collect(node);
56
+ }
57
+ return result;
58
+ }
59
+ /**
60
+ * Check if aggregates are on the row axis (i.e., row headers contain _aggregate dimension).
61
+ */
62
+ function areAggregatesOnRowAxis(rowHeaders) {
63
+ function checkNode(node) {
64
+ if (node.dimension === '_aggregate') {
65
+ return true;
66
+ }
67
+ if (node.children) {
68
+ for (const child of node.children) {
69
+ if (checkNode(child))
70
+ return true;
71
+ }
72
+ }
73
+ return false;
74
+ }
75
+ for (const header of rowHeaders) {
76
+ if (checkNode(header))
77
+ return true;
78
+ }
79
+ return false;
80
+ }
81
+ /**
82
+ * Render column header rows.
83
+ */
84
+ function renderColumnHeaders(grid, lines, showDimensionLabels) {
85
+ // Get row header column count - if corner headers, we still have columns but labels go in corner
86
+ const rowHeaderCols = grid.useCornerRowHeaders
87
+ ? (grid.cornerRowLabels?.length ?? 0)
88
+ : countRowHeaderColumns(grid.rowHeaders);
89
+ // Check if aggregates are rendered as row headers
90
+ const aggregatesOnRowAxis = areAggregatesOnRowAxis(grid.rowHeaders);
91
+ if (grid.colHeaders.length === 0 && grid.aggregates.length === 1) {
92
+ // No column headers - single aggregate with row-only layout
93
+ // Still need a header row for the aggregate
94
+ lines.push('<thead>');
95
+ lines.push('<tr>');
96
+ // Corner cells for row header columns - with labels if corner style or left mode with custom labels
97
+ if (grid.useCornerRowHeaders && grid.cornerRowLabels) {
98
+ for (const labelInfo of grid.cornerRowLabels) {
99
+ const hasLabel = labelInfo.label.trim() !== '';
100
+ const classes = hasLabel ? 'tpl-corner tpl-corner-label' : 'tpl-corner';
101
+ lines.push(`<th class="${classes}">${escapeHTML(labelInfo.label)}</th>`);
102
+ }
103
+ }
104
+ else if (grid.leftModeRowLabels) {
105
+ for (let i = 0; i < rowHeaderCols; i++) {
106
+ const labelInfo = grid.leftModeRowLabels[i];
107
+ if (labelInfo?.hasCustomLabel) {
108
+ lines.push(`<th class="tpl-corner tpl-corner-label">${escapeHTML(labelInfo.label)}</th>`);
109
+ }
110
+ else {
111
+ lines.push('<th class="tpl-corner"></th>');
112
+ }
113
+ }
114
+ }
115
+ else {
116
+ for (let i = 0; i < rowHeaderCols; i++) {
117
+ lines.push('<th class="tpl-corner"></th>');
118
+ }
119
+ }
120
+ // Single aggregate header
121
+ lines.push(`<th>${escapeHTML(grid.aggregates[0]?.label ?? grid.aggregates[0]?.name ?? 'Value')}</th>`);
122
+ lines.push('</tr>');
123
+ lines.push('</thead>');
124
+ return;
125
+ }
126
+ if (grid.colHeaders.length === 0) {
127
+ // Multiple aggregates but no column dimensions
128
+ lines.push('<thead>');
129
+ lines.push('<tr>');
130
+ // Corner cells with labels if corner style or left mode with custom labels
131
+ if (grid.useCornerRowHeaders && grid.cornerRowLabels) {
132
+ for (const labelInfo of grid.cornerRowLabels) {
133
+ const hasLabel = labelInfo.label.trim() !== '';
134
+ const classes = hasLabel ? 'tpl-corner tpl-corner-label' : 'tpl-corner';
135
+ lines.push(`<th class="${classes}">${escapeHTML(labelInfo.label)}</th>`);
136
+ }
137
+ }
138
+ else if (grid.leftModeRowLabels) {
139
+ for (let i = 0; i < rowHeaderCols; i++) {
140
+ const labelInfo = grid.leftModeRowLabels[i];
141
+ if (labelInfo?.hasCustomLabel) {
142
+ lines.push(`<th class="tpl-corner tpl-corner-label">${escapeHTML(labelInfo.label)}</th>`);
143
+ }
144
+ else {
145
+ lines.push('<th class="tpl-corner"></th>');
146
+ }
147
+ }
148
+ }
149
+ else {
150
+ for (let i = 0; i < rowHeaderCols; i++) {
151
+ lines.push('<th class="tpl-corner"></th>');
152
+ }
153
+ }
154
+ // If aggregates are on the row axis, render a single "Value" column header
155
+ // (each row will have one cell for its specific aggregate)
156
+ if (aggregatesOnRowAxis) {
157
+ lines.push('<th>Value</th>');
158
+ }
159
+ else {
160
+ // Aggregates are not on row axis, render each aggregate as a column header
161
+ for (const agg of grid.aggregates) {
162
+ lines.push(`<th>${escapeHTML(agg.label ?? agg.name)}</th>`);
163
+ }
164
+ }
165
+ lines.push('</tr>');
166
+ lines.push('</thead>');
167
+ return;
168
+ }
169
+ // Multi-level column headers
170
+ const maxDepth = getMaxDepth(grid.colHeaders);
171
+ lines.push('<thead>');
172
+ for (let depth = 0; depth <= maxDepth; depth++) {
173
+ lines.push('<tr>');
174
+ // Corner cells for row header columns - render per row instead of using rowspan
175
+ // Labels only appear in the last row (depth === maxDepth)
176
+ const isLastRow = depth === maxDepth;
177
+ if (grid.useCornerRowHeaders && grid.cornerRowLabels) {
178
+ // Corner-style: render row dimension labels in corner on the LAST row of thead
179
+ for (let i = 0; i < rowHeaderCols; i++) {
180
+ if (isLastRow) {
181
+ const labelText = grid.cornerRowLabels[i]?.label ?? '';
182
+ const hasLabel = labelText.trim() !== '';
183
+ const classes = hasLabel ? 'tpl-corner tpl-corner-label' : 'tpl-corner';
184
+ lines.push(`<th class="${classes}">${escapeHTML(labelText)}</th>`);
185
+ }
186
+ else {
187
+ lines.push('<th class="tpl-corner"></th>');
188
+ }
189
+ }
190
+ }
191
+ else if (grid.leftModeRowLabels) {
192
+ // Left mode with sibling structure: show only custom labels in last row
193
+ for (let i = 0; i < rowHeaderCols; i++) {
194
+ if (isLastRow) {
195
+ const labelInfo = grid.leftModeRowLabels[i];
196
+ if (labelInfo?.hasCustomLabel) {
197
+ lines.push(`<th class="tpl-corner tpl-corner-label">${escapeHTML(labelInfo.label)}</th>`);
198
+ }
199
+ else {
200
+ lines.push('<th class="tpl-corner"></th>');
201
+ }
202
+ }
203
+ else {
204
+ lines.push('<th class="tpl-corner"></th>');
205
+ }
206
+ }
207
+ }
208
+ else {
209
+ // Default: empty corner cells
210
+ for (let i = 0; i < rowHeaderCols; i++) {
211
+ lines.push('<th class="tpl-corner"></th>');
212
+ }
213
+ }
214
+ // Column headers at this depth
215
+ const nodesAtDepth = getNodesAtDepth(grid.colHeaders, depth);
216
+ for (const node of nodesAtDepth) {
217
+ const rowspan = node.children ? 1 : (maxDepth - node.depth + 1);
218
+ const colspan = node.span > 1 ? ` colspan="${node.span}"` : '';
219
+ const rowspanAttr = rowspan > 1 ? ` rowspan="${rowspan}"` : '';
220
+ const cssClasses = [];
221
+ if (node.type === 'total')
222
+ cssClasses.push('total-col');
223
+ if (node.type === 'sibling-label')
224
+ cssClasses.push('sibling-label');
225
+ const cssClass = cssClasses.length > 0 ? ` class="${cssClasses.join(' ')}"` : '';
226
+ lines.push(`<th${colspan}${rowspanAttr}${cssClass}>${escapeHTML(node.value)}</th>`);
227
+ }
228
+ lines.push('</tr>');
229
+ }
230
+ lines.push('</thead>');
231
+ }
232
+ /**
233
+ * Count the number of columns needed for row headers.
234
+ */
235
+ function countRowHeaderColumns(rowHeaders) {
236
+ if (rowHeaders.length === 0)
237
+ return 0;
238
+ // Count depth levels (each level gets a column)
239
+ const maxDepth = getMaxDepth(rowHeaders);
240
+ return maxDepth + 1;
241
+ }
242
+ // ---
243
+ // BODY (ROW HEADERS + DATA)
244
+ // ---
245
+ /**
246
+ * Collect all leaf header nodes (nodes without children).
247
+ */
248
+ function collectLeafNodes(nodes) {
249
+ const leaves = [];
250
+ function collect(node) {
251
+ if (!node.children || node.children.length === 0) {
252
+ leaves.push(node);
253
+ }
254
+ else {
255
+ for (const child of node.children) {
256
+ collect(child);
257
+ }
258
+ }
259
+ }
260
+ for (const node of nodes) {
261
+ collect(node);
262
+ }
263
+ return leaves;
264
+ }
265
+ /**
266
+ * Collect dimension values from root to a leaf node.
267
+ */
268
+ function collectDimensionValues(leaf, allNodes) {
269
+ const values = new Map();
270
+ // Build path from root to this leaf
271
+ // We need to find ancestors by matching spans and positions
272
+ // For simplicity, we'll just collect from the leaf and its siblings structure
273
+ // Add the leaf's dimension value
274
+ if (leaf.dimension && leaf.dimension !== '_aggregate') {
275
+ const numValue = Number(leaf.value);
276
+ values.set(leaf.dimension, isNaN(numValue) ? leaf.value : numValue);
277
+ }
278
+ // TODO: For nested headers, we need to track the path through the tree
279
+ // This is simplified - for now we only handle flat row dimensions
280
+ return values;
281
+ }
282
+ /**
283
+ * Render table body with row headers and data cells.
284
+ */
285
+ function renderBody(grid, lines, showDimensionLabels) {
286
+ lines.push('<tbody>');
287
+ // Get all leaf row nodes (each becomes a data row)
288
+ const rowLeaves = collectLeafNodes(grid.rowHeaders);
289
+ const colLeaves = grid.colHeaders.length > 0 ? collectLeafNodes(grid.colHeaders) : [];
290
+ // Track which row headers have been rendered (for rowspan)
291
+ const maxRowDepth = getMaxDepth(grid.rowHeaders);
292
+ const renderedAt = new Map(); // "depth:value" -> rendered
293
+ for (let rowIdx = 0; rowIdx < rowLeaves.length; rowIdx++) {
294
+ lines.push('<tr>');
295
+ // Render row headers for this row
296
+ renderRowHeaders(grid, rowLeaves, rowIdx, maxRowDepth, renderedAt, lines);
297
+ // Render data cells
298
+ const rowValues = collectRowDimensionValues(grid.rowHeaders, rowLeaves[rowIdx]);
299
+ const rowLeaf = rowLeaves[rowIdx];
300
+ if (colLeaves.length > 0) {
301
+ // With column pivots
302
+ for (const colLeaf of colLeaves) {
303
+ const colValues = collectColDimensionValues(grid.colHeaders, colLeaf);
304
+ renderDataCell(grid, rowValues, colValues, rowLeaf, colLeaf, grid.rowHeaders, grid.colHeaders, lines);
305
+ }
306
+ }
307
+ else {
308
+ // No column pivots - render aggregate values directly
309
+ renderDataCells(grid, rowValues, rowLeaf, lines);
310
+ }
311
+ lines.push('</tr>');
312
+ }
313
+ lines.push('</tbody>');
314
+ }
315
+ /**
316
+ * Render row header cells for a data row.
317
+ */
318
+ function renderRowHeaders(grid, rowLeaves, rowIdx, maxDepth, renderedAt, lines) {
319
+ // Find the path from root to this leaf
320
+ const leaf = rowLeaves[rowIdx];
321
+ const path = findPathToLeaf(grid.rowHeaders, leaf);
322
+ // When using corner row headers, skip sibling-label nodes (their labels are in corner)
323
+ // and adjust depths accordingly
324
+ const useCornerHeaders = grid.useCornerRowHeaders;
325
+ // Filter path to only include renderable nodes for corner style
326
+ const renderablePath = useCornerHeaders
327
+ ? path.filter(n => n.type !== 'sibling-label')
328
+ : path;
329
+ // Calculate effective max depth for corner style
330
+ const effectiveMaxDepth = useCornerHeaders
331
+ ? (grid.cornerRowLabels?.length ?? 1) - 1
332
+ : maxDepth;
333
+ for (let depth = 0; depth <= effectiveMaxDepth; depth++) {
334
+ // For corner headers, find the node that matches this column position
335
+ // (nodes are already filtered to exclude sibling-labels)
336
+ const nodeAtDepth = useCornerHeaders
337
+ ? renderablePath[depth]
338
+ : path.find(n => n.depth === depth);
339
+ if (nodeAtDepth) {
340
+ const key = `${depth}:${nodeAtDepth.value}:${getPathKey(path, depth)}`;
341
+ if (!renderedAt.has(key)) {
342
+ // First time seeing this header - render with rowspan
343
+ renderedAt.set(key, true);
344
+ const rowspan = nodeAtDepth.span > 1 ? ` rowspan="${nodeAtDepth.span}"` : '';
345
+ const cssClasses = [];
346
+ if (nodeAtDepth.type === 'total')
347
+ cssClasses.push('total-row');
348
+ if (nodeAtDepth.type === 'sibling-label' && !useCornerHeaders)
349
+ cssClasses.push('sibling-label');
350
+ const cssClass = cssClasses.length > 0 ? ` class="${cssClasses.join(' ')}"` : '';
351
+ // If this is a leaf node that doesn't reach maxDepth, add colspan to fill remaining columns
352
+ const isLeaf = useCornerHeaders
353
+ ? (depth === renderablePath.length - 1)
354
+ : (nodeAtDepth === leaf);
355
+ const remainingDepth = effectiveMaxDepth - depth;
356
+ const colspan = isLeaf && remainingDepth > 0 ? ` colspan="${remainingDepth + 1}"` : '';
357
+ lines.push(`<th${rowspan}${colspan}${cssClass}>${escapeHTML(nodeAtDepth.value)}</th>`);
358
+ // If we added colspan, skip the remaining depths in this iteration
359
+ if (isLeaf && remainingDepth > 0) {
360
+ break;
361
+ }
362
+ }
363
+ // else: already rendered, skip (covered by rowspan)
364
+ }
365
+ else {
366
+ // No node at this depth for this row - might happen with siblings
367
+ // Leave as empty (rowspan should cover from a parent)
368
+ }
369
+ }
370
+ }
371
+ /**
372
+ * Find the path from root to a leaf node.
373
+ */
374
+ function findPathToLeaf(roots, target) {
375
+ function search(node, path) {
376
+ const currentPath = [...path, node];
377
+ if (node === target) {
378
+ return currentPath;
379
+ }
380
+ if (node.children) {
381
+ for (const child of node.children) {
382
+ const result = search(child, currentPath);
383
+ if (result)
384
+ return result;
385
+ }
386
+ }
387
+ return null;
388
+ }
389
+ for (const root of roots) {
390
+ const result = search(root, []);
391
+ if (result)
392
+ return result;
393
+ }
394
+ return [target]; // Fallback: just the target
395
+ }
396
+ /**
397
+ * Get a unique key for a path up to a certain depth.
398
+ * Includes sibling indices from the node's path to distinguish headers
399
+ * in different sibling groups that have the same dimension/value.
400
+ */
401
+ function getPathKey(path, upToDepth) {
402
+ const nodesUpToDepth = path.filter(n => n.depth <= upToDepth);
403
+ return nodesUpToDepth
404
+ .map(n => {
405
+ // Include sibling indices from the node's path for uniqueness
406
+ const siblingPrefix = n.path
407
+ ?.filter(seg => seg.type === 'sibling')
408
+ .map(seg => seg.index)
409
+ .join(',') ?? '';
410
+ return `${siblingPrefix}:${n.dimension}:${n.value}`;
411
+ })
412
+ .join('|');
413
+ }
414
+ /**
415
+ * Collect row dimension values from the path to a leaf.
416
+ */
417
+ function collectRowDimensionValues(roots, leaf) {
418
+ const values = new Map();
419
+ const path = findPathToLeaf(roots, leaf);
420
+ for (const node of path) {
421
+ if (node.dimension && node.dimension !== '_aggregate') {
422
+ const numValue = Number(node.value);
423
+ values.set(node.dimension, isNaN(numValue) ? node.value : numValue);
424
+ }
425
+ }
426
+ return values;
427
+ }
428
+ /**
429
+ * Collect column dimension values from the path to a leaf.
430
+ */
431
+ function collectColDimensionValues(roots, leaf) {
432
+ const values = new Map();
433
+ const path = findPathToLeaf(roots, leaf);
434
+ for (const node of path) {
435
+ if (node.dimension && node.dimension !== '_aggregate') {
436
+ const numValue = Number(node.value);
437
+ values.set(node.dimension, isNaN(numValue) ? node.value : numValue);
438
+ }
439
+ }
440
+ return values;
441
+ }
442
+ /**
443
+ * Find the aggregate name from a header node with _aggregate dimension.
444
+ *
445
+ * First checks the path for an aggregate segment (most reliable for sibling aggregates),
446
+ * then falls back to matching by label/name.
447
+ */
448
+ function findAggregateFromHeader(header, aggregates) {
449
+ if (header.dimension !== '_aggregate')
450
+ return undefined;
451
+ // First, check the path for an aggregate segment - this is the most reliable method
452
+ // because it captures the exact aggregate from the tree structure
453
+ for (const segment of header.path) {
454
+ if (segment.type === 'aggregate') {
455
+ // Verify this aggregate exists in our list
456
+ const agg = aggregates.find(a => a.name === segment.name);
457
+ if (agg) {
458
+ return agg.name;
459
+ }
460
+ }
461
+ }
462
+ // Fallback: Match by label or formatted name
463
+ const agg = aggregates.find(a => a.label === header.value ||
464
+ a.name === header.value ||
465
+ formatAggName(a.name) === header.value);
466
+ return agg?.name;
467
+ }
468
+ /**
469
+ * Format aggregate name for display matching (e.g., "births_sum" -> "births sum")
470
+ */
471
+ function formatAggName(name) {
472
+ return name.replace(/_/g, ' ');
473
+ }
474
+ /**
475
+ * Check if a header node is or is part of a total path.
476
+ */
477
+ function isInTotalPath(leaf, roots) {
478
+ const path = findPathToLeaf(roots, leaf);
479
+ return path.some(node => node.type === 'total');
480
+ }
481
+ /**
482
+ * Render a single data cell.
483
+ */
484
+ function renderDataCell(grid, rowValues, colValues, rowLeaf, colLeaf, rowRoots, colRoots, lines) {
485
+ // Determine which aggregate to use - check both row and column
486
+ let aggregateName;
487
+ // Check if row has aggregate (e.g., ROWS ... * (sum | mean))
488
+ aggregateName = findAggregateFromHeader(rowLeaf, grid.aggregates);
489
+ // Check if column has aggregate (e.g., COLS ... * (sum | mean))
490
+ if (!aggregateName) {
491
+ aggregateName = findAggregateFromHeader(colLeaf, grid.aggregates);
492
+ }
493
+ const cell = grid.getCell(rowValues, colValues, aggregateName);
494
+ // Build CSS classes for totals
495
+ const classes = [];
496
+ if (isInTotalPath(rowLeaf, rowRoots)) {
497
+ classes.push('total-row');
498
+ }
499
+ if (isInTotalPath(colLeaf, colRoots)) {
500
+ classes.push('total-col');
501
+ }
502
+ const classAttr = classes.length > 0 ? ` class="${classes.join(' ')}"` : '';
503
+ lines.push(`<td${classAttr}>${escapeHTML(cell.formatted)}</td>`);
504
+ }
505
+ /**
506
+ * Render data cells when there are no column pivots.
507
+ */
508
+ function renderDataCells(grid, rowValues, rowLeaf, lines) {
509
+ const colValues = new Map();
510
+ // Check if row specifies the aggregate
511
+ const rowAgg = findAggregateFromHeader(rowLeaf, grid.aggregates);
512
+ // Check if this row is a total row
513
+ const isTotalRow = isInTotalPath(rowLeaf, grid.rowHeaders);
514
+ const classAttr = isTotalRow ? ' class="total-row"' : '';
515
+ if (rowAgg) {
516
+ // Single aggregate cell based on row header
517
+ const cell = grid.getCell(rowValues, colValues, rowAgg);
518
+ lines.push(`<td${classAttr}>${escapeHTML(cell.formatted)}</td>`);
519
+ }
520
+ else {
521
+ // Multiple aggregate cells
522
+ for (const agg of grid.aggregates) {
523
+ const cell = grid.getCell(rowValues, colValues, agg.name);
524
+ lines.push(`<td${classAttr}>${escapeHTML(cell.formatted)}</td>`);
525
+ }
526
+ }
527
+ }
528
+ // ---
529
+ // UTILITIES
530
+ // ---
531
+ /**
532
+ * Escape HTML special characters.
533
+ */
534
+ function escapeHTML(str) {
535
+ return str
536
+ .replace(/&/g, '&amp;')
537
+ .replace(/</g, '&lt;')
538
+ .replace(/>/g, '&gt;')
539
+ .replace(/"/g, '&quot;')
540
+ .replace(/'/g, '&#39;');
541
+ }
@@ -0,0 +1,4 @@
1
+ /**
2
+ * renderer package - gridspec to HTML
3
+ */
4
+ export { renderGridToHTML, type GridRenderOptions, } from './grid-renderer.js';
@@ -0,0 +1,4 @@
1
+ /**
2
+ * renderer package - gridspec to HTML
3
+ */
4
+ export { renderGridToHTML, } from './grid-renderer.js';
package/package.json ADDED
@@ -0,0 +1,67 @@
1
+ {
2
+ "name": "tplm-lang",
3
+ "version": "0.1.0",
4
+ "description": "TPLm - Table Producing Language backed by Malloy.",
5
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "types": "./dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "import": "./dist/index.js",
11
+ "types": "./dist/index.d.ts"
12
+ }
13
+ },
14
+ "files": [
15
+ "dist/**/*.js",
16
+ "dist/**/*.d.ts",
17
+ "packages/parser/tpl.pegjs",
18
+ "packages/renderer/tpl-table.css",
19
+ "README.md",
20
+ "LICENSE"
21
+ ],
22
+ "scripts": {
23
+ "build:parser": "peggy --format es packages/parser/tpl.pegjs -o packages/parser/parser.js && mkdir -p dist/parser && cp packages/parser/parser.js packages/parser/parser.d.ts dist/parser/",
24
+ "build": "npm run build:parser && tsc",
25
+ "test": "vitest",
26
+ "test:run": "vitest run",
27
+ "docs:dev": "cd docs-site && npm run docs:dev",
28
+ "docs:build": "cd docs-site && npm run docs:build",
29
+ "docs:preview": "cd docs-site && npm run docs:preview"
30
+ },
31
+ "devDependencies": {
32
+ "@types/node": "^20.0.0",
33
+ "peggy": "^4.0.0",
34
+ "typescript": "^5.0.0",
35
+ "vitest": "^4.0.16"
36
+ },
37
+ "dependencies": {
38
+ "@malloydata/db-bigquery": "^0.0.326",
39
+ "@malloydata/db-duckdb": "^0.0.326",
40
+ "@malloydata/malloy": "^0.0.326",
41
+ "chevrotain": "^11.0.3"
42
+ },
43
+ "engines": {
44
+ "node": ">=18.0.0"
45
+ },
46
+ "keywords": [
47
+ "tplm",
48
+ "tpl",
49
+ "table",
50
+ "crosstab",
51
+ "pivot",
52
+ "analytics",
53
+ "malloy",
54
+ "duckdb",
55
+ "bigquery",
56
+ "data-visualization"
57
+ ],
58
+ "license": "MIT",
59
+ "repository": {
60
+ "type": "git",
61
+ "url": "https://github.com/jasonphillips/tplm"
62
+ },
63
+ "homepage": "https://github.com/jasonphillips/tplm#readme",
64
+ "bugs": {
65
+ "url": "https://github.com/jasonphillips/tplm/issues"
66
+ }
67
+ }