@nachoggodino/cello 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.
- package/BYLAWS.md +446 -0
- package/CHANGELOG.md +11 -0
- package/LICENSE +12 -0
- package/README.md +211 -0
- package/dist/cli/cli.d.ts +16 -0
- package/dist/cli/cli.js +360 -0
- package/dist/cli/serve.d.ts +15 -0
- package/dist/cli/serve.js +226 -0
- package/dist/evaluator/evaluate.d.ts +2 -0
- package/dist/evaluator/evaluate.js +129 -0
- package/dist/evaluator/formula.d.ts +13 -0
- package/dist/evaluator/formula.js +141 -0
- package/dist/formatter/format.d.ts +1 -0
- package/dist/formatter/format.js +112 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +6 -0
- package/dist/parser/parse.d.ts +2 -0
- package/dist/parser/parse.js +552 -0
- package/dist/renderer/render.d.ts +2 -0
- package/dist/renderer/render.js +295 -0
- package/dist/serializer/serialize.d.ts +2 -0
- package/dist/serializer/serialize.js +104 -0
- package/dist/shared/types.d.ts +88 -0
- package/dist/shared/types.js +1 -0
- package/dist/shared/utils.d.ts +16 -0
- package/dist/shared/utils.js +142 -0
- package/dist/validator/validate.d.ts +8 -0
- package/dist/validator/validate.js +10 -0
- package/dist/version.d.ts +1 -0
- package/dist/version.js +1 -0
- package/docs/ARCHITECTURE.md +43 -0
- package/docs/CLI.md +58 -0
- package/docs/COMPLIANCE.md +82 -0
- package/docs/ERROR_MODEL.md +25 -0
- package/docs/FORMULA_SUPPORT.md +33 -0
- package/docs/SPEC.md +723 -0
- package/docs/SYNTAX_HIGHLIGHTING.md +91 -0
- package/examples/advanced_kpi.cel +42 -0
- package/examples/basic.cel +8 -0
- package/examples/feature_showcase.cel +37 -0
- package/package.json +96 -0
- package/syntaxes/cel.language-configuration.json +31 -0
- package/syntaxes/cel.tmLanguage.json +250 -0
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
import { evaluate } from "../evaluator/evaluate.js";
|
|
2
|
+
import { parse } from "../parser/parse.js";
|
|
3
|
+
import { columnLetter, escapeHtml, workbookHasFormulas } from "../shared/utils.js";
|
|
4
|
+
export async function render(input, options = {}) {
|
|
5
|
+
const parseOptions = {
|
|
6
|
+
...(options.strict === undefined ? {} : { strict: options.strict }),
|
|
7
|
+
...(options.baseDir === undefined ? {} : { baseDir: options.baseDir }),
|
|
8
|
+
...(options.readExternalSource === undefined ? {} : { readExternalSource: options.readExternalSource })
|
|
9
|
+
};
|
|
10
|
+
const parsed = typeof input === "string" ? parse(input, parseOptions) : input;
|
|
11
|
+
const shouldEvaluate = options.evaluate !== false && workbookHasFormulas(parsed);
|
|
12
|
+
const evaluated = shouldEvaluate ? await evaluate(parsed, parseOptions) : parsed;
|
|
13
|
+
const workbookHtml = renderWorkbook(renderTabs(evaluated), renderSheets(evaluated));
|
|
14
|
+
return options.format === "fragment"
|
|
15
|
+
? renderFragment(workbookHtml, options.interactive !== false)
|
|
16
|
+
: renderDocument(options.title ?? "Cello Workbook", workbookHtml, options.interactive !== false);
|
|
17
|
+
}
|
|
18
|
+
function renderDocument(title, workbookHtml, interactive) {
|
|
19
|
+
return `<!doctype html>
|
|
20
|
+
<html lang="en">
|
|
21
|
+
<head>
|
|
22
|
+
<meta charset="utf-8" />
|
|
23
|
+
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
|
24
|
+
<title>${escapeHtml(title)}</title>
|
|
25
|
+
${renderStyles()}
|
|
26
|
+
</head>
|
|
27
|
+
<body>
|
|
28
|
+
${workbookHtml}
|
|
29
|
+
${interactive ? renderScript() : ""}
|
|
30
|
+
</body>
|
|
31
|
+
</html>`;
|
|
32
|
+
}
|
|
33
|
+
function renderFragment(workbookHtml, interactive) {
|
|
34
|
+
return `${renderStyles()}
|
|
35
|
+
${workbookHtml}
|
|
36
|
+
${interactive ? renderScript() : ""}`;
|
|
37
|
+
}
|
|
38
|
+
function renderStyles() {
|
|
39
|
+
return `<style>
|
|
40
|
+
.cello-workbook {
|
|
41
|
+
font-family: Inter, Segoe UI, Arial, sans-serif;
|
|
42
|
+
color: #111827;
|
|
43
|
+
--cello-tone-ok-color: #166534;
|
|
44
|
+
--cello-tone-ok-background: #dcfce7;
|
|
45
|
+
--cello-tone-warn-color: #9a3412;
|
|
46
|
+
--cello-tone-warn-background: #ffedd5;
|
|
47
|
+
--cello-tone-error-color: #991b1b;
|
|
48
|
+
--cello-tone-error-background: #fee2e2;
|
|
49
|
+
--cello-tone-info-color: #1d4ed8;
|
|
50
|
+
--cello-tone-info-background: #dbeafe;
|
|
51
|
+
--cello-tone-muted-color: #475569;
|
|
52
|
+
--cello-tone-muted-background: #e2e8f0;
|
|
53
|
+
--cello-tone-accent-color: #6d28d9;
|
|
54
|
+
--cello-tone-accent-background: #ede9fe;
|
|
55
|
+
}
|
|
56
|
+
.cello-tabs { display: flex; gap: 8px; overflow-x: auto; margin-bottom: 12px; }
|
|
57
|
+
.cello-tab { border: 1px solid #d1d5db; background: #ffffff; padding: 6px 10px; border-radius: 6px; cursor: pointer; }
|
|
58
|
+
.cello-tab.active { background: #111827; color: #ffffff; border-color: #111827; }
|
|
59
|
+
.cello-sheet { display: none; }
|
|
60
|
+
.cello-sheet.active { display: block; }
|
|
61
|
+
table { border-collapse: collapse; width: max-content; max-width: none; }
|
|
62
|
+
th, td { border: 1px solid #e5e7eb; padding: 8px; text-align: left; vertical-align: top; white-space: nowrap; }
|
|
63
|
+
th[colspan], th[rowspan], td[colspan], td[rowspan] { text-align: center; vertical-align: middle; }
|
|
64
|
+
th { background: #f3f4f6; font-weight: 600; }
|
|
65
|
+
.cello-corner-index, .cello-column-index, .cello-row-index { background: #f9fafb; color: #6b7280; font-size: 0.75rem; font-weight: 600; text-align: center; }
|
|
66
|
+
.cello-corner-index, .cello-row-index { min-width: 36px; }
|
|
67
|
+
.cello-bold { font-weight: 700; }
|
|
68
|
+
.cello-italic { font-style: italic; }
|
|
69
|
+
.cello-h1 { font-size: 1.25rem; font-weight: 700; }
|
|
70
|
+
.cello-h2 { font-size: 1.1rem; font-weight: 700; }
|
|
71
|
+
.cello-tone-ok { color: var(--cello-tone-ok-color); background: var(--cello-tone-ok-background); }
|
|
72
|
+
.cello-tone-warn { color: var(--cello-tone-warn-color); background: var(--cello-tone-warn-background); }
|
|
73
|
+
.cello-tone-error { color: var(--cello-tone-error-color); background: var(--cello-tone-error-background); }
|
|
74
|
+
.cello-tone-info { color: var(--cello-tone-info-color); background: var(--cello-tone-info-background); }
|
|
75
|
+
.cello-tone-muted { color: var(--cello-tone-muted-color); background: var(--cello-tone-muted-background); }
|
|
76
|
+
.cello-tone-accent { color: var(--cello-tone-accent-color); background: var(--cello-tone-accent-background); }
|
|
77
|
+
</style>`;
|
|
78
|
+
}
|
|
79
|
+
function renderWorkbook(tabs, sheetsHtml) {
|
|
80
|
+
return `<div class="cello-workbook">
|
|
81
|
+
<div class="cello-tabs">${tabs}</div>
|
|
82
|
+
${sheetsHtml}
|
|
83
|
+
</div>`;
|
|
84
|
+
}
|
|
85
|
+
function renderScript() {
|
|
86
|
+
return `<script>
|
|
87
|
+
(() => {
|
|
88
|
+
const currentScript = document.currentScript;
|
|
89
|
+
const root = currentScript?.previousElementSibling;
|
|
90
|
+
if (!(root instanceof HTMLElement) || !root.classList.contains("cello-workbook")) {
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
const tabs = Array.from(root.querySelectorAll(".cello-tab"));
|
|
94
|
+
const sheets = Array.from(root.querySelectorAll(".cello-sheet"));
|
|
95
|
+
const activeSheetStorageKey = "cello:active-sheet:" + window.location.pathname;
|
|
96
|
+
function readStoredSheet() {
|
|
97
|
+
try {
|
|
98
|
+
return window.localStorage.getItem(activeSheetStorageKey);
|
|
99
|
+
} catch {
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
function writeStoredSheet(id) {
|
|
104
|
+
try {
|
|
105
|
+
window.localStorage.setItem(activeSheetStorageKey, id);
|
|
106
|
+
} catch {}
|
|
107
|
+
}
|
|
108
|
+
function activateSheet(id) {
|
|
109
|
+
const nextTab = tabs.find((tab) => tab.getAttribute("data-sheet") === id) ?? tabs[0];
|
|
110
|
+
if (!nextTab) {
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
const nextId = nextTab.getAttribute("data-sheet");
|
|
114
|
+
tabs.forEach((tab) => tab.classList.toggle("active", tab === nextTab));
|
|
115
|
+
sheets.forEach((sheet) => sheet.classList.toggle("active", sheet.getAttribute("data-sheet") === nextId));
|
|
116
|
+
if (nextId) {
|
|
117
|
+
writeStoredSheet(nextId);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
tabs.forEach((tab) => {
|
|
121
|
+
tab.addEventListener("click", () => {
|
|
122
|
+
activateSheet(tab.getAttribute("data-sheet"));
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
activateSheet(readStoredSheet());
|
|
126
|
+
})();
|
|
127
|
+
</script>`;
|
|
128
|
+
}
|
|
129
|
+
function renderTabs(workbook) {
|
|
130
|
+
return workbook.sheets
|
|
131
|
+
.map((sheet, idx) => `<button class="cello-tab ${idx === 0 ? "active" : ""}" data-sheet="${escapeHtml(sheet.name)}">${escapeHtml(sheet.name)}</button>`)
|
|
132
|
+
.join("");
|
|
133
|
+
}
|
|
134
|
+
function renderSheets(workbook) {
|
|
135
|
+
return workbook.sheets
|
|
136
|
+
.map((sheet, idx) => `<section class="cello-sheet ${idx === 0 ? "active" : ""}" data-sheet="${escapeHtml(sheet.name)}"><table>${renderColumnIndexRow(sheet)}<tbody>${sheet.rows
|
|
137
|
+
.map((row) => renderRow(row, sheet))
|
|
138
|
+
.join("")}</tbody></table></section>`)
|
|
139
|
+
.join("");
|
|
140
|
+
}
|
|
141
|
+
function renderColumnIndexRow(sheet) {
|
|
142
|
+
const columns = Array.from({ length: getSheetColumnCount(sheet) }, (_, idx) => `<th class="cello-column-index">${columnLetter(idx + 1)}</th>`).join("");
|
|
143
|
+
return `<thead><tr><th class="cello-corner-index"></th>${columns}</tr></thead>`;
|
|
144
|
+
}
|
|
145
|
+
function renderRow(row, sheet) {
|
|
146
|
+
const header = row.kind === "header";
|
|
147
|
+
const cells = row.cells.filter(isRenderableCell).map((cell) => renderCell(cell, header, collectModifiers(cell, row, sheet))).join("");
|
|
148
|
+
return `<tr><th class="cello-row-index" scope="row">${row.index}</th>${cells}</tr>`;
|
|
149
|
+
}
|
|
150
|
+
function renderCell(cell, header, modifiers) {
|
|
151
|
+
const tag = header ? "th" : "td";
|
|
152
|
+
const formatted = formatInline(formatDisplayValue(renderCellValue(cell), modifiers));
|
|
153
|
+
const attrs = buildCellAttributes(cell, modifiers);
|
|
154
|
+
return `<${tag} ${attrs}>${formatted}</${tag}>`;
|
|
155
|
+
}
|
|
156
|
+
function renderCellValue(cell) {
|
|
157
|
+
if (cell.computed !== undefined) {
|
|
158
|
+
return cell.computed;
|
|
159
|
+
}
|
|
160
|
+
if (cell.kind === "formula" && cell.formula) {
|
|
161
|
+
return cell.formula;
|
|
162
|
+
}
|
|
163
|
+
return cell.value;
|
|
164
|
+
}
|
|
165
|
+
function collectModifiers(cell, row, sheet) {
|
|
166
|
+
const columnModifiers = row.kind === "header" ? [] : (sheet.columns[cell.col - 1]?.modifiers ?? []);
|
|
167
|
+
return [...columnModifiers, ...row.modifiers, ...cell.modifiers];
|
|
168
|
+
}
|
|
169
|
+
function buildCellAttributes(cell, modifiers) {
|
|
170
|
+
const className = buildClassAttribute(modifiers);
|
|
171
|
+
const style = buildStyleAttribute(modifiers);
|
|
172
|
+
return [
|
|
173
|
+
cell.colspan > 1 ? `colspan="${cell.colspan}"` : "",
|
|
174
|
+
cell.rowspan > 1 ? `rowspan="${cell.rowspan}"` : "",
|
|
175
|
+
className,
|
|
176
|
+
style
|
|
177
|
+
]
|
|
178
|
+
.filter(Boolean)
|
|
179
|
+
.join(" ");
|
|
180
|
+
}
|
|
181
|
+
function formatInline(raw) {
|
|
182
|
+
if (raw.startsWith("## ")) {
|
|
183
|
+
return `<span class="cello-h1">${escapeHtml(raw.slice(3))}</span>`;
|
|
184
|
+
}
|
|
185
|
+
if (raw.startsWith("# ")) {
|
|
186
|
+
return `<span class="cello-h2">${escapeHtml(raw.slice(2))}</span>`;
|
|
187
|
+
}
|
|
188
|
+
let out = escapeHtml(raw);
|
|
189
|
+
out = out.replace(/\*([^*]+)\*/g, "<span class=\"cello-bold\">$1</span>");
|
|
190
|
+
out = out.replace(/_([^_]+)_/g, "<span class=\"cello-italic\">$1</span>");
|
|
191
|
+
out = out.replace(/~~([^~]+)~~/g, "<del>$1</del>");
|
|
192
|
+
return out;
|
|
193
|
+
}
|
|
194
|
+
function buildStyleAttribute(modifiers) {
|
|
195
|
+
const style = modifiers.map(toStyleRule).filter(Boolean).join(";");
|
|
196
|
+
return style ? `style="${style}"` : "";
|
|
197
|
+
}
|
|
198
|
+
function buildClassAttribute(modifiers) {
|
|
199
|
+
const classes = modifiers.map(toClassName).filter(Boolean);
|
|
200
|
+
return classes.length > 0 ? `class="${classes.join(" ")}"` : "";
|
|
201
|
+
}
|
|
202
|
+
function formatDisplayValue(value, modifiers) {
|
|
203
|
+
if (typeof value !== "number" || !Number.isFinite(value)) {
|
|
204
|
+
return String(value);
|
|
205
|
+
}
|
|
206
|
+
const format = collectNumericDisplayFormat(modifiers);
|
|
207
|
+
if (!format) {
|
|
208
|
+
return String(value);
|
|
209
|
+
}
|
|
210
|
+
const scaled = format.percent ? value * 100 : value;
|
|
211
|
+
const numberText = format.decimals === undefined ? String(scaled) : scaled.toFixed(format.decimals);
|
|
212
|
+
return `${format.currency ?? ""}${numberText}${format.percent ? "%" : ""}`;
|
|
213
|
+
}
|
|
214
|
+
function collectNumericDisplayFormat(modifiers) {
|
|
215
|
+
const format = { percent: false };
|
|
216
|
+
let found = false;
|
|
217
|
+
for (const mod of modifiers) {
|
|
218
|
+
if (isCurrencyModifier(mod.key)) {
|
|
219
|
+
format.currency = mod.key;
|
|
220
|
+
found = true;
|
|
221
|
+
continue;
|
|
222
|
+
}
|
|
223
|
+
if (mod.key === "%") {
|
|
224
|
+
format.percent = true;
|
|
225
|
+
found = true;
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
228
|
+
const decimals = parseDecimalsModifier(mod.key);
|
|
229
|
+
if (decimals !== undefined) {
|
|
230
|
+
format.decimals = decimals;
|
|
231
|
+
found = true;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
return found ? format : undefined;
|
|
235
|
+
}
|
|
236
|
+
function isCurrencyModifier(key) {
|
|
237
|
+
return key === "€" || key === "$" || key === "£";
|
|
238
|
+
}
|
|
239
|
+
function parseDecimalsModifier(key) {
|
|
240
|
+
const match = /^(\d+)d$/.exec(key);
|
|
241
|
+
if (!match) {
|
|
242
|
+
return undefined;
|
|
243
|
+
}
|
|
244
|
+
const decimals = Number(match[1]);
|
|
245
|
+
return Number.isSafeInteger(decimals) ? decimals : undefined;
|
|
246
|
+
}
|
|
247
|
+
function isRenderableCell(cell) {
|
|
248
|
+
return cell.kind !== "merge-left" && cell.kind !== "merge-up";
|
|
249
|
+
}
|
|
250
|
+
function getSheetColumnCount(sheet) {
|
|
251
|
+
const maxRenderedColumn = Math.max(0, ...sheet.rows.flatMap((row) => row.cells
|
|
252
|
+
.filter(isRenderableCell)
|
|
253
|
+
.map((cell) => cell.col + Math.max(cell.colspan, 1) - 1)));
|
|
254
|
+
return Math.max(sheet.columns.length, maxRenderedColumn);
|
|
255
|
+
}
|
|
256
|
+
function toStyleRule(mod) {
|
|
257
|
+
if (mod.key === "bold") {
|
|
258
|
+
return "font-weight:700";
|
|
259
|
+
}
|
|
260
|
+
if (mod.key === "italic") {
|
|
261
|
+
return "font-style:italic";
|
|
262
|
+
}
|
|
263
|
+
if (mod.key === "bg" && mod.value) {
|
|
264
|
+
return `background:${mod.value}`;
|
|
265
|
+
}
|
|
266
|
+
if (mod.key === "bgfg" && mod.value) {
|
|
267
|
+
const [background = "", foreground = ""] = mod.value.split(":");
|
|
268
|
+
return [background ? `background:${background}` : "", foreground ? `color:${foreground}` : ""].filter(Boolean).join(";");
|
|
269
|
+
}
|
|
270
|
+
if (mod.key.startsWith("#")) {
|
|
271
|
+
return `color:${mod.key}`;
|
|
272
|
+
}
|
|
273
|
+
if (mod.key === "color" && mod.value) {
|
|
274
|
+
return `color:${mod.value}`;
|
|
275
|
+
}
|
|
276
|
+
if (mod.key === "tone") {
|
|
277
|
+
return "";
|
|
278
|
+
}
|
|
279
|
+
if (isNamedColorModifier(mod.key)) {
|
|
280
|
+
return `color:${mod.key}`;
|
|
281
|
+
}
|
|
282
|
+
return "";
|
|
283
|
+
}
|
|
284
|
+
function toClassName(mod) {
|
|
285
|
+
if (mod.key !== "tone" || !mod.value) {
|
|
286
|
+
return "";
|
|
287
|
+
}
|
|
288
|
+
return isToneName(mod.value) ? `cello-tone-${mod.value}` : "";
|
|
289
|
+
}
|
|
290
|
+
function isNamedColorModifier(key) {
|
|
291
|
+
return /^[a-z]+$/.test(key) && !["bold", "default", "italic", "hidden"].includes(key);
|
|
292
|
+
}
|
|
293
|
+
function isToneName(value) {
|
|
294
|
+
return ["ok", "warn", "error", "info", "muted", "accent"].includes(value);
|
|
295
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
export function serialize(ast) {
|
|
2
|
+
const chunks = [];
|
|
3
|
+
for (const [sheetIndex, sheet] of ast.sheets.entries()) {
|
|
4
|
+
if (sheetIndex > 0) {
|
|
5
|
+
chunks.push("");
|
|
6
|
+
}
|
|
7
|
+
chunks.push(`@sheet ${sheet.name}${formatToToken(sheet.format)}`);
|
|
8
|
+
for (const row of sheet.rows) {
|
|
9
|
+
chunks.push(stringifyRow(row));
|
|
10
|
+
if (row.kind === "header") {
|
|
11
|
+
const defaultsRow = stringifyDefaultsRow(sheet, row);
|
|
12
|
+
if (defaultsRow) {
|
|
13
|
+
chunks.push(defaultsRow);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
return chunks.join("\n");
|
|
19
|
+
}
|
|
20
|
+
function stringifyRow(row) {
|
|
21
|
+
if (row.kind === "header") {
|
|
22
|
+
return stringifyHeaderRow(row);
|
|
23
|
+
}
|
|
24
|
+
return stringifyDataRow(row);
|
|
25
|
+
}
|
|
26
|
+
function stringifyHeaderRow(row) {
|
|
27
|
+
const cells = row.cells
|
|
28
|
+
.filter((c) => c.kind !== "merge-left" && c.kind !== "merge-up")
|
|
29
|
+
.map((c) => `${stringifyCellBase(c)}${stringifyModifiers(c.modifiers.filter((modifier) => modifier.key !== "default"))}`)
|
|
30
|
+
.join(" | ");
|
|
31
|
+
return `@header | ${cells} |`;
|
|
32
|
+
}
|
|
33
|
+
function stringifyDataRow(row) {
|
|
34
|
+
const cells = row.cells.map((cell) => stringifyCell(cell)).join(" | ");
|
|
35
|
+
const rowPrefix = row.modifiers.length > 0 ? `${stringifyModifiers(row.modifiers)} ` : "";
|
|
36
|
+
return `${rowPrefix}| ${cells} |`;
|
|
37
|
+
}
|
|
38
|
+
function stringifyDefaultsRow(sheet, row) {
|
|
39
|
+
const cells = sheet.columns.map((column) => {
|
|
40
|
+
const columnDefault = findDefaultModifier(column.modifiers);
|
|
41
|
+
if (!columnDefault) {
|
|
42
|
+
return "";
|
|
43
|
+
}
|
|
44
|
+
return columnDefault.value ?? "";
|
|
45
|
+
});
|
|
46
|
+
if (!cells.some((cell) => cell.length > 0)) {
|
|
47
|
+
return undefined;
|
|
48
|
+
}
|
|
49
|
+
return `@defaults | ${cells.join(" | ")} |`;
|
|
50
|
+
}
|
|
51
|
+
function findDefaultModifier(modifiers) {
|
|
52
|
+
return modifiers.find((modifier) => modifier.key === "default");
|
|
53
|
+
}
|
|
54
|
+
function formatToToken(format) {
|
|
55
|
+
if (format.kind === "cello") {
|
|
56
|
+
return "";
|
|
57
|
+
}
|
|
58
|
+
if (format.kind === "markdown") {
|
|
59
|
+
return " [markdown]";
|
|
60
|
+
}
|
|
61
|
+
if (format.kind === "json") {
|
|
62
|
+
return format.path ? ` [json:${format.path}]` : " [json]";
|
|
63
|
+
}
|
|
64
|
+
const delimiter = format.alias ? format.alias : format.delimiter === "\t" ? "\\t" : format.delimiter;
|
|
65
|
+
const suffix = format.noHeader ? ":noheader" : "";
|
|
66
|
+
return ` [${delimiter}${suffix}]`;
|
|
67
|
+
}
|
|
68
|
+
function stringifyCell(cell) {
|
|
69
|
+
if (cell.kind === "merge-left") {
|
|
70
|
+
return "<";
|
|
71
|
+
}
|
|
72
|
+
if (cell.kind === "merge-up") {
|
|
73
|
+
return "^";
|
|
74
|
+
}
|
|
75
|
+
const base = stringifyCellBase(cell);
|
|
76
|
+
return `${base}${stringifyModifiers(cell.modifiers)}`;
|
|
77
|
+
}
|
|
78
|
+
function stringifyCellBase(cell) {
|
|
79
|
+
if (cell.kind === "formula" && cell.formula) {
|
|
80
|
+
return cell.formula;
|
|
81
|
+
}
|
|
82
|
+
return stringifyScalar(cell.value);
|
|
83
|
+
}
|
|
84
|
+
function stringifyModifiers(modifiers) {
|
|
85
|
+
if (modifiers.length === 0) {
|
|
86
|
+
return "";
|
|
87
|
+
}
|
|
88
|
+
return modifiers.map((m) => `[${m.raw}]`).join("");
|
|
89
|
+
}
|
|
90
|
+
function stringifyScalar(value) {
|
|
91
|
+
if (value === null || value === undefined) {
|
|
92
|
+
return "";
|
|
93
|
+
}
|
|
94
|
+
if (typeof value === "string") {
|
|
95
|
+
return value;
|
|
96
|
+
}
|
|
97
|
+
if (typeof value === "number") {
|
|
98
|
+
return String(value);
|
|
99
|
+
}
|
|
100
|
+
if (typeof value === "boolean") {
|
|
101
|
+
return value ? "TRUE" : "FALSE";
|
|
102
|
+
}
|
|
103
|
+
return String(value);
|
|
104
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
export type CelloVersion = "1.0";
|
|
2
|
+
export type SheetFormat = {
|
|
3
|
+
kind: "cello";
|
|
4
|
+
} | {
|
|
5
|
+
kind: "delimited";
|
|
6
|
+
delimiter: string;
|
|
7
|
+
noHeader: boolean;
|
|
8
|
+
alias?: "csv" | "tsv" | "excel";
|
|
9
|
+
} | {
|
|
10
|
+
kind: "markdown";
|
|
11
|
+
} | {
|
|
12
|
+
kind: "json";
|
|
13
|
+
path?: string;
|
|
14
|
+
};
|
|
15
|
+
export type RowKind = "header" | "data";
|
|
16
|
+
export type CellKind = "value" | "formula" | "merge-left" | "merge-up" | "empty";
|
|
17
|
+
export type InferredType = "number" | "date" | "boolean" | "text" | "empty";
|
|
18
|
+
export interface Modifier {
|
|
19
|
+
raw: string;
|
|
20
|
+
key: string;
|
|
21
|
+
value?: string;
|
|
22
|
+
}
|
|
23
|
+
export interface CellNode {
|
|
24
|
+
row: number;
|
|
25
|
+
col: number;
|
|
26
|
+
raw: string;
|
|
27
|
+
kind: CellKind;
|
|
28
|
+
inferredType: InferredType;
|
|
29
|
+
value: string | number | boolean | null;
|
|
30
|
+
formula?: string;
|
|
31
|
+
modifiers: Modifier[];
|
|
32
|
+
computed?: string | number | boolean | null;
|
|
33
|
+
colspan: number;
|
|
34
|
+
rowspan: number;
|
|
35
|
+
}
|
|
36
|
+
export interface RowNode {
|
|
37
|
+
index: number;
|
|
38
|
+
kind: RowKind;
|
|
39
|
+
sourceLine: number;
|
|
40
|
+
modifiers: Modifier[];
|
|
41
|
+
cells: CellNode[];
|
|
42
|
+
}
|
|
43
|
+
export interface ColumnNode {
|
|
44
|
+
index: number;
|
|
45
|
+
letter: string;
|
|
46
|
+
name?: string;
|
|
47
|
+
modifiers: Modifier[];
|
|
48
|
+
hidden: boolean;
|
|
49
|
+
}
|
|
50
|
+
export interface SheetNode {
|
|
51
|
+
name: string;
|
|
52
|
+
format: SheetFormat;
|
|
53
|
+
rows: RowNode[];
|
|
54
|
+
columns: ColumnNode[];
|
|
55
|
+
}
|
|
56
|
+
export type DiagnosticLevel = "warning" | "error";
|
|
57
|
+
export interface Diagnostic {
|
|
58
|
+
level: DiagnosticLevel;
|
|
59
|
+
message: string;
|
|
60
|
+
line?: number;
|
|
61
|
+
sheet?: string;
|
|
62
|
+
}
|
|
63
|
+
export interface WorkbookAst {
|
|
64
|
+
version: CelloVersion;
|
|
65
|
+
sheets: SheetNode[];
|
|
66
|
+
diagnostics: Diagnostic[];
|
|
67
|
+
}
|
|
68
|
+
export interface ParseOptions {
|
|
69
|
+
strict?: boolean;
|
|
70
|
+
anonymousSheetName?: string;
|
|
71
|
+
baseDir?: string;
|
|
72
|
+
readExternalSource?: (path: string, context: {
|
|
73
|
+
baseDir: string;
|
|
74
|
+
resolvedPath: string;
|
|
75
|
+
}) => string;
|
|
76
|
+
}
|
|
77
|
+
export interface EvaluateOptions {
|
|
78
|
+
strict?: boolean;
|
|
79
|
+
}
|
|
80
|
+
export interface RenderOptions {
|
|
81
|
+
strict?: boolean;
|
|
82
|
+
title?: string;
|
|
83
|
+
baseDir?: string;
|
|
84
|
+
readExternalSource?: ParseOptions["readExternalSource"];
|
|
85
|
+
evaluate?: boolean;
|
|
86
|
+
format?: "document" | "fragment";
|
|
87
|
+
interactive?: boolean;
|
|
88
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { Modifier, SheetFormat, WorkbookAst } from "./types.js";
|
|
2
|
+
export declare function columnLetter(index: number): string;
|
|
3
|
+
export declare function deepClone<T>(value: T): T;
|
|
4
|
+
export declare function escapeHtml(value: string): string;
|
|
5
|
+
export declare function parseTrailingModifiers(value: string): {
|
|
6
|
+
base: string;
|
|
7
|
+
modifiers: Modifier[];
|
|
8
|
+
};
|
|
9
|
+
export declare function parseModifier(raw: string): Modifier;
|
|
10
|
+
export declare function inferType(value: string): {
|
|
11
|
+
inferredType: "number" | "date" | "boolean" | "text" | "empty";
|
|
12
|
+
parsed: string | number | boolean | null;
|
|
13
|
+
};
|
|
14
|
+
export declare function parseSheetFormat(rawFormat?: string): SheetFormat;
|
|
15
|
+
export declare function splitDelimitedLine(line: string, delimiter: string): string[];
|
|
16
|
+
export declare function workbookHasFormulas(workbook: WorkbookAst): boolean;
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
export function columnLetter(index) {
|
|
2
|
+
let n = index;
|
|
3
|
+
let out = "";
|
|
4
|
+
while (n > 0) {
|
|
5
|
+
const rem = (n - 1) % 26;
|
|
6
|
+
out = String.fromCharCode(65 + rem) + out;
|
|
7
|
+
n = Math.floor((n - 1) / 26);
|
|
8
|
+
}
|
|
9
|
+
return out || "A";
|
|
10
|
+
}
|
|
11
|
+
export function deepClone(value) {
|
|
12
|
+
if (typeof structuredClone === "function") {
|
|
13
|
+
return structuredClone(value);
|
|
14
|
+
}
|
|
15
|
+
return JSON.parse(JSON.stringify(value));
|
|
16
|
+
}
|
|
17
|
+
export function escapeHtml(value) {
|
|
18
|
+
return value
|
|
19
|
+
.replaceAll("&", "&")
|
|
20
|
+
.replaceAll("<", "<")
|
|
21
|
+
.replaceAll(">", ">")
|
|
22
|
+
.replaceAll('"', """)
|
|
23
|
+
.replaceAll("'", "'");
|
|
24
|
+
}
|
|
25
|
+
export function parseTrailingModifiers(value) {
|
|
26
|
+
let rest = value.trimEnd();
|
|
27
|
+
const modifiers = [];
|
|
28
|
+
while (rest.endsWith("]")) {
|
|
29
|
+
const open = rest.lastIndexOf("[");
|
|
30
|
+
if (open < 0) {
|
|
31
|
+
break;
|
|
32
|
+
}
|
|
33
|
+
const rawContent = rest.slice(open + 1, -1);
|
|
34
|
+
if (rawContent.includes("[") || rawContent.includes("]")) {
|
|
35
|
+
break;
|
|
36
|
+
}
|
|
37
|
+
modifiers.unshift(parseModifier(rawContent));
|
|
38
|
+
rest = rest.slice(0, open).trimEnd();
|
|
39
|
+
}
|
|
40
|
+
return { base: rest.trim(), modifiers };
|
|
41
|
+
}
|
|
42
|
+
export function parseModifier(raw) {
|
|
43
|
+
if (raw.startsWith("#bg:")) {
|
|
44
|
+
const [background = "", foreground = ""] = raw
|
|
45
|
+
.slice(4)
|
|
46
|
+
.split(":")
|
|
47
|
+
.map((part) => part.trim());
|
|
48
|
+
return { raw, key: "bgfg", value: `${background}:${foreground}` };
|
|
49
|
+
}
|
|
50
|
+
if (raw.includes(":")) {
|
|
51
|
+
const [key, ...rest] = raw.split(":");
|
|
52
|
+
return { raw, key: (key ?? "").trim().toLowerCase(), value: rest.join(":").trim() };
|
|
53
|
+
}
|
|
54
|
+
return { raw, key: raw.trim().toLowerCase() };
|
|
55
|
+
}
|
|
56
|
+
export function inferType(value) {
|
|
57
|
+
const trimmed = value.trim();
|
|
58
|
+
if (trimmed.length === 0) {
|
|
59
|
+
return { inferredType: "empty", parsed: null };
|
|
60
|
+
}
|
|
61
|
+
if (trimmed.startsWith('"') && trimmed.endsWith('"') && trimmed.length >= 2) {
|
|
62
|
+
return { inferredType: "text", parsed: trimmed.slice(1, -1) };
|
|
63
|
+
}
|
|
64
|
+
if (trimmed === "TRUE") {
|
|
65
|
+
return { inferredType: "boolean", parsed: true };
|
|
66
|
+
}
|
|
67
|
+
if (trimmed === "FALSE") {
|
|
68
|
+
return { inferredType: "boolean", parsed: false };
|
|
69
|
+
}
|
|
70
|
+
if (/^\d{4}-\d{2}-\d{2}$/.test(trimmed)) {
|
|
71
|
+
return { inferredType: "date", parsed: trimmed };
|
|
72
|
+
}
|
|
73
|
+
const maybeNumber = Number(trimmed);
|
|
74
|
+
if (!Number.isNaN(maybeNumber) && Number.isFinite(maybeNumber)) {
|
|
75
|
+
return { inferredType: "number", parsed: maybeNumber };
|
|
76
|
+
}
|
|
77
|
+
return { inferredType: "text", parsed: trimmed };
|
|
78
|
+
}
|
|
79
|
+
export function parseSheetFormat(rawFormat) {
|
|
80
|
+
if (!rawFormat || rawFormat.trim().length === 0) {
|
|
81
|
+
return { kind: "cello" };
|
|
82
|
+
}
|
|
83
|
+
const token = rawFormat.trim();
|
|
84
|
+
const lower = token.toLowerCase();
|
|
85
|
+
if (lower === "markdown") {
|
|
86
|
+
return { kind: "markdown" };
|
|
87
|
+
}
|
|
88
|
+
if (lower.startsWith("json")) {
|
|
89
|
+
const parts = token.split(":");
|
|
90
|
+
if (parts.length > 1) {
|
|
91
|
+
return { kind: "json", path: parts.slice(1).join(":") };
|
|
92
|
+
}
|
|
93
|
+
return { kind: "json" };
|
|
94
|
+
}
|
|
95
|
+
for (const [alias, delimiter] of [
|
|
96
|
+
["csv", ","],
|
|
97
|
+
["tsv", "\t"],
|
|
98
|
+
["excel", ";"]
|
|
99
|
+
]) {
|
|
100
|
+
if (lower.startsWith(alias)) {
|
|
101
|
+
return { kind: "delimited", delimiter, noHeader: lower.includes("noheader"), alias };
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
const [formatHead, ...flags] = token.split(":");
|
|
105
|
+
const noHeader = flags.some((f) => f.trim().toLowerCase() === "noheader");
|
|
106
|
+
if (formatHead === "\\t") {
|
|
107
|
+
return { kind: "delimited", delimiter: "\t", noHeader };
|
|
108
|
+
}
|
|
109
|
+
if (formatHead && formatHead.length === 1) {
|
|
110
|
+
return { kind: "delimited", delimiter: formatHead, noHeader };
|
|
111
|
+
}
|
|
112
|
+
return { kind: "cello" };
|
|
113
|
+
}
|
|
114
|
+
export function splitDelimitedLine(line, delimiter) {
|
|
115
|
+
const output = [];
|
|
116
|
+
let current = "";
|
|
117
|
+
let inQuotes = false;
|
|
118
|
+
for (let i = 0; i < line.length; i += 1) {
|
|
119
|
+
const ch = line[i];
|
|
120
|
+
if (ch === '"') {
|
|
121
|
+
if (inQuotes && i + 1 < line.length && line[i + 1] === '"') {
|
|
122
|
+
current += '"';
|
|
123
|
+
i += 1;
|
|
124
|
+
}
|
|
125
|
+
else {
|
|
126
|
+
inQuotes = !inQuotes;
|
|
127
|
+
}
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
if (!inQuotes && ch === delimiter) {
|
|
131
|
+
output.push(current);
|
|
132
|
+
current = "";
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
current += ch;
|
|
136
|
+
}
|
|
137
|
+
output.push(current);
|
|
138
|
+
return output;
|
|
139
|
+
}
|
|
140
|
+
export function workbookHasFormulas(workbook) {
|
|
141
|
+
return workbook.sheets.some((sheet) => sheet.rows.some((row) => row.cells.some((cell) => cell.kind === "formula")));
|
|
142
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { Diagnostic, EvaluateOptions, ParseOptions } from "../shared/types.js";
|
|
2
|
+
export interface ValidateOptions extends ParseOptions, EvaluateOptions {
|
|
3
|
+
}
|
|
4
|
+
export interface ValidateResult {
|
|
5
|
+
valid: boolean;
|
|
6
|
+
diagnostics: Diagnostic[];
|
|
7
|
+
}
|
|
8
|
+
export declare function validate(text: string, options?: ValidateOptions): Promise<ValidateResult>;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { evaluate } from "../evaluator/evaluate.js";
|
|
2
|
+
import { parse } from "../parser/parse.js";
|
|
3
|
+
export async function validate(text, options = {}) {
|
|
4
|
+
const ast = parse(text, options);
|
|
5
|
+
const evaluated = await evaluate(ast, options);
|
|
6
|
+
return {
|
|
7
|
+
valid: evaluated.diagnostics.length === 0,
|
|
8
|
+
diagnostics: evaluated.diagnostics
|
|
9
|
+
};
|
|
10
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const VERSION = "0.1.0";
|
package/dist/version.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const VERSION = "0.1.0";
|