mirror of
https://github.com/docmost/docmost.git
synced 2026-08-22 10:22:12 +10:00
Merge branch 'main' into feat/integrations
# Conflicts: # apps/client/src/App.tsx # apps/server/src/ee # apps/server/src/integrations/queue/constants/queue.constants.ts # apps/server/src/integrations/queue/queue.module.ts # packages/editor-ext/src/index.ts
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
dist/
|
||||
tsconfig.tsbuildinfo
|
||||
@@ -0,0 +1,37 @@
|
||||
The Docmost Enterprise License (the “Enterprise License”)
|
||||
Copyright (c) 2023-present Docmost, Inc
|
||||
|
||||
|
||||
With regard to the Docmost Software:
|
||||
|
||||
This software and associated documentation files (the "Software") may only be
|
||||
used in production, if you (and any entity that you represent) have agreed to,
|
||||
and are in compliance with, the Docmost Subscription Terms of Service, available
|
||||
at https://docmost.com/terms (the “Enterprise Terms”), or other
|
||||
agreement governing the use of the Software, as agreed by you and Docmost, Inc.,
|
||||
and otherwise have a valid Docmost Enterprise Edition subscription for the correct number of user seats.
|
||||
Subject to the foregoing sentence, you are free to
|
||||
modify this Software and publish patches to the Software. You agree that Docmost
|
||||
and/or its licensors (as applicable) retain all right, title and interest in and
|
||||
to all such modifications and/or patches, and all such modifications and/or
|
||||
patches may only be used, copied, modified, displayed, distributed, or otherwise
|
||||
exploited with a valid Docmost Enterprise Edition subscription for the correct
|
||||
number of user seats. Notwithstanding the foregoing, you may copy and modify
|
||||
the Software for development and testing purposes, without requiring a
|
||||
subscription. You agree that Docmost and/or its licensors (as applicable) retain
|
||||
all right, title and interest in and to all such modifications. You are not
|
||||
granted any other rights beyond what is expressly stated herein. Subject to the
|
||||
foregoing, it is forbidden to copy, merge, publish, distribute, sublicense,
|
||||
and/or sell the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
For all third party components incorporated into the Docmost Software, those
|
||||
components are licensed under the original license provided by the owner of the
|
||||
applicable component.
|
||||
@@ -0,0 +1,329 @@
|
||||
import {
|
||||
parseRaw,
|
||||
resolve,
|
||||
typecheck,
|
||||
evaluate,
|
||||
registry,
|
||||
DEFAULT_MAX_DEPTH,
|
||||
} from "../src/index.server";
|
||||
import type {
|
||||
FormulaAST,
|
||||
EvalContext,
|
||||
PropertyLookup,
|
||||
Value,
|
||||
FormulaResultType,
|
||||
} from "../src/index.server";
|
||||
|
||||
// sample row: properties a..j (numbers), name (string)
|
||||
const NUM_PROPS = ["a", "b", "c", "d", "e", "f", "g", "h", "i", "j"];
|
||||
const cells: Record<string, unknown> = {
|
||||
prop_name: "widget",
|
||||
};
|
||||
NUM_PROPS.forEach((p, idx) => {
|
||||
cells[`prop_${p}`] = (idx + 1) * 7.3 - idx; // arbitrary non-trivial floats
|
||||
});
|
||||
|
||||
const nameToId = new Map<string, string>([
|
||||
["name", "prop_name"],
|
||||
...NUM_PROPS.map((p) => [p, `prop_${p}`] as [string, string]),
|
||||
]);
|
||||
|
||||
const propertyTypes = new Map<string, FormulaResultType>([
|
||||
["prop_name", "string"],
|
||||
...NUM_PROPS.map(
|
||||
(p) => [`prop_${p}`, "number"] as [string, FormulaResultType],
|
||||
),
|
||||
]);
|
||||
|
||||
// Base (non-formula) property lookup. Nested-formula cases extend this.
|
||||
const baseProps = new Map<string, PropertyLookup>([
|
||||
["prop_name", { id: "prop_name", type: "string", typeOptions: {} }],
|
||||
...NUM_PROPS.map(
|
||||
(p) =>
|
||||
[`prop_${p}`, { id: `prop_${p}`, type: "number", typeOptions: {} }] as [
|
||||
string,
|
||||
PropertyLookup,
|
||||
],
|
||||
),
|
||||
]);
|
||||
|
||||
function mkCtx(properties: ReadonlyMap<string, PropertyLookup>): EvalContext {
|
||||
return {
|
||||
registry,
|
||||
properties,
|
||||
depth: 0,
|
||||
maxDepth: DEFAULT_MAX_DEPTH,
|
||||
memo: new Map<string, Value>(),
|
||||
};
|
||||
}
|
||||
|
||||
//AST shape metrics
|
||||
function astStats(ast: FormulaAST): { nodes: number; depth: number } {
|
||||
let nodes = 0;
|
||||
const walk = (n: FormulaAST, d: number): number => {
|
||||
nodes++;
|
||||
let max = d;
|
||||
const kids: FormulaAST[] = [];
|
||||
switch (n.t) {
|
||||
case "op":
|
||||
kids.push(...n.args);
|
||||
break;
|
||||
case "and":
|
||||
case "or":
|
||||
kids.push(...n.args);
|
||||
break;
|
||||
case "call":
|
||||
kids.push(...n.args);
|
||||
break;
|
||||
case "if":
|
||||
kids.push(n.cond, n.then, n.else);
|
||||
break;
|
||||
}
|
||||
for (const k of kids) max = Math.max(max, walk(k, d + 1));
|
||||
return max;
|
||||
};
|
||||
const depth = walk(ast, 1);
|
||||
return { nodes, depth };
|
||||
}
|
||||
|
||||
// timing harness
|
||||
function timed(
|
||||
fn: () => void,
|
||||
targetMs = 600,
|
||||
): { opsPerSec: number; nsPerOp: number } {
|
||||
// warmup ~150ms to let V8 JIT settle
|
||||
const warmEnd = performance.now() + 150;
|
||||
while (performance.now() < warmEnd) fn();
|
||||
|
||||
// measure: 5 samples, keep the fastest (least noise from GC/scheduler)
|
||||
let bestNsPerOp = Infinity;
|
||||
for (let s = 0; s < 5; s++) {
|
||||
// calibrate batch so each sample ~ targetMs
|
||||
let iters = 1024;
|
||||
let elapsedMs = 0;
|
||||
while (true) {
|
||||
const t0 = process.hrtime.bigint();
|
||||
for (let i = 0; i < iters; i++) fn();
|
||||
const t1 = process.hrtime.bigint();
|
||||
elapsedMs = Number(t1 - t0) / 1e6;
|
||||
if (elapsedMs >= targetMs) break;
|
||||
iters = Math.ceil(
|
||||
iters * Math.max(2, targetMs / Math.max(elapsedMs, 0.01)),
|
||||
);
|
||||
}
|
||||
const nsPerOp = (elapsedMs * 1e6) / iters;
|
||||
bestNsPerOp = Math.min(bestNsPerOp, nsPerOp);
|
||||
}
|
||||
return { opsPerSec: 1e9 / bestNsPerOp, nsPerOp: bestNsPerOp };
|
||||
}
|
||||
|
||||
// formula corpus
|
||||
type Case = { tier: string; name: string; src: string };
|
||||
|
||||
function buildArithChain(n: number): string {
|
||||
// ((((a + b) * c) - d) ... ) cycling through props/ops
|
||||
const ops = ["+", "*", "-"];
|
||||
let expr = 'prop("a")';
|
||||
for (let i = 0; i < n; i++) {
|
||||
const p = NUM_PROPS[(i + 1) % NUM_PROPS.length];
|
||||
const op = ops[i % ops.length];
|
||||
expr = `(${expr} ${op} prop("${p}"))`;
|
||||
}
|
||||
return expr;
|
||||
}
|
||||
|
||||
function buildIfChain(tiers: number): string {
|
||||
// if(a>t1, "1", if(a>t2, "2", ... "fallback"))
|
||||
let expr = '"fallback"';
|
||||
for (let i = tiers; i >= 1; i--) {
|
||||
expr = `if(prop("a") > ${i * 5}, "${i}", ${expr})`;
|
||||
}
|
||||
return expr;
|
||||
}
|
||||
|
||||
function buildBalancedAddTree(depth: number): string {
|
||||
// add(add(.., ..), add(.., ..)) = full binary tree of `add` calls
|
||||
const leaf = () =>
|
||||
`prop("${NUM_PROPS[Math.floor(Math.random() * NUM_PROPS.length)]}")`;
|
||||
const build = (d: number): string =>
|
||||
d === 0 ? leaf() : `add(${build(d - 1)}, ${build(d - 1)})`;
|
||||
return build(depth);
|
||||
}
|
||||
|
||||
const cases: Case[] = [
|
||||
// BASIC
|
||||
{ tier: "basic", name: "literal add", src: "1 + 2" },
|
||||
{ tier: "basic", name: "two-prop add", src: 'prop("a") + prop("b")' },
|
||||
{ tier: "basic", name: "comparison", src: 'prop("a") > 10' },
|
||||
{ tier: "basic", name: "neg + mul", src: '-prop("a") * 2' },
|
||||
|
||||
// INTERMEDIATE
|
||||
{
|
||||
tier: "intermediate",
|
||||
name: "round(mul)",
|
||||
src: 'round(prop("a") * 1.5, 2)',
|
||||
},
|
||||
{
|
||||
tier: "intermediate",
|
||||
name: "if/then/else",
|
||||
src: 'if(prop("a") > prop("b"), "hi", "lo")',
|
||||
},
|
||||
{
|
||||
tier: "intermediate",
|
||||
name: "string concat",
|
||||
src: 'concat(upper(prop("name")), "-", toString(prop("a")))',
|
||||
},
|
||||
{
|
||||
tier: "intermediate",
|
||||
name: "bool and/or",
|
||||
src: 'and(prop("a") > 0, or(prop("b") < 100, prop("c") == 0))',
|
||||
},
|
||||
|
||||
// COMPLEX
|
||||
{
|
||||
tier: "complex",
|
||||
name: "hypotenuse",
|
||||
src: 'sqrt(pow(prop("a"), 2) + pow(prop("b"), 2))',
|
||||
},
|
||||
{
|
||||
tier: "complex",
|
||||
name: "sum(10 props)",
|
||||
src: `sum(${NUM_PROPS.map((p) => `prop("${p}")`).join(", ")})`,
|
||||
},
|
||||
{
|
||||
tier: "complex",
|
||||
name: "nested if (4-tier grade)",
|
||||
src: 'if(prop("a") > 90, "A", if(prop("a") > 80, "B", if(prop("a") > 70, "C", "F")))',
|
||||
},
|
||||
{
|
||||
tier: "complex",
|
||||
name: "mixed math+string+logic",
|
||||
src: 'if(and(prop("a") > 0, prop("b") > 0), concat("ok:", toString(round(prop("a") / prop("b"), 2))), "n/a")',
|
||||
},
|
||||
|
||||
// DEEPLY NESTED
|
||||
{ tier: "deep", name: "arith chain x20", src: buildArithChain(20) },
|
||||
{ tier: "deep", name: "nested if x10 tiers", src: buildIfChain(10) },
|
||||
{ tier: "deep", name: "balanced fn tree d6", src: buildBalancedAddTree(6) },
|
||||
];
|
||||
|
||||
// nested-formula (cross-property) case
|
||||
// prop_total (formula) -> prop_sub (formula) -> raw props. Exercises evalProp
|
||||
// recursion + per-row memoization, the multi-formula recompute hot path.
|
||||
function buildNestedFormulaCtx(): {
|
||||
ast: FormulaAST;
|
||||
ctx: EvalContext;
|
||||
stats: { nodes: number; depth: number };
|
||||
} {
|
||||
const subRaw = resolve(
|
||||
parseRaw('round((prop("a") + prop("b") + prop("c")) / 3, 2)'),
|
||||
nameToId,
|
||||
).ast;
|
||||
const totalRaw = resolve(
|
||||
parseRaw('prop("sub") * prop("d") + prop("e")'),
|
||||
// @ts-ignore
|
||||
new Map([...nameToId, ["sub", "prop_sub"]]),
|
||||
).ast;
|
||||
|
||||
const props = new Map<string, PropertyLookup>(baseProps);
|
||||
props.set("prop_sub", {
|
||||
id: "prop_sub",
|
||||
type: "formula",
|
||||
typeOptions: {
|
||||
ast: subRaw,
|
||||
source: "",
|
||||
resultType: "number",
|
||||
dependencies: [],
|
||||
astVersion: 1,
|
||||
},
|
||||
});
|
||||
return { ast: totalRaw, ctx: mkCtx(props), stats: astStats(totalRaw) };
|
||||
}
|
||||
|
||||
// run
|
||||
const fmt = (n: number) =>
|
||||
n >= 1e6
|
||||
? `${(n / 1e6).toFixed(2)}M`
|
||||
: n >= 1e3
|
||||
? `${(n / 1e3).toFixed(1)}K`
|
||||
: n.toFixed(0);
|
||||
|
||||
console.log(`\nnode ${process.version} | base-formula engine benchmark\n`);
|
||||
console.log(
|
||||
"tier".padEnd(13) +
|
||||
"formula".padEnd(28) +
|
||||
"nodes".padStart(6) +
|
||||
"depth".padStart(6) +
|
||||
"compile op/s".padStart(15) +
|
||||
"eval op/s".padStart(13) +
|
||||
"eval ns/op".padStart(13),
|
||||
);
|
||||
console.log("-".repeat(94));
|
||||
|
||||
for (const c of cases) {
|
||||
const raw = parseRaw(c.src);
|
||||
const { ast } = resolve(raw, nameToId);
|
||||
const stats = astStats(ast);
|
||||
const ctx = mkCtx(baseProps);
|
||||
|
||||
const compile = timed(() => {
|
||||
const r = resolve(parseRaw(c.src), nameToId);
|
||||
typecheck(r.ast, propertyTypes, registry);
|
||||
});
|
||||
const ev = timed(() => {
|
||||
ctx.memo.clear(); // fresh per "row" — matches production new Map() per row
|
||||
evaluate(ast, cells, ctx);
|
||||
});
|
||||
|
||||
console.log(
|
||||
c.tier.padEnd(13) +
|
||||
c.name.padEnd(28) +
|
||||
String(stats.nodes).padStart(6) +
|
||||
String(stats.depth).padStart(6) +
|
||||
fmt(compile.opsPerSec).padStart(15) +
|
||||
fmt(ev.opsPerSec).padStart(13) +
|
||||
ev.nsPerOp.toFixed(0).padStart(13),
|
||||
);
|
||||
}
|
||||
|
||||
// nested cross-property formula
|
||||
{
|
||||
const { ast, ctx, stats } = buildNestedFormulaCtx();
|
||||
const ev = timed(() => {
|
||||
ctx.memo.clear();
|
||||
evaluate(ast, cells, ctx);
|
||||
});
|
||||
console.log(
|
||||
"nested-prop".padEnd(13) +
|
||||
"total->sub->raw".padEnd(28) +
|
||||
String(stats.nodes).padStart(6) +
|
||||
String(stats.depth).padStart(6) +
|
||||
"-".padStart(15) +
|
||||
fmt(ev.opsPerSec).padStart(13) +
|
||||
ev.nsPerOp.toFixed(0).padStart(13),
|
||||
);
|
||||
}
|
||||
|
||||
// whole-table simulation: eval N rows for the complex grade formula
|
||||
console.log(
|
||||
"\nwhole-table recompute simulation (mixed math+string+logic formula):",
|
||||
);
|
||||
const tableAst = resolve(
|
||||
parseRaw(
|
||||
'if(and(prop("a") > 0, prop("b") > 0), concat("ok:", toString(round(prop("a") / prop("b"), 2))), "n/a")',
|
||||
),
|
||||
nameToId,
|
||||
).ast;
|
||||
for (const rows of [1_000, 10_000, 100_000]) {
|
||||
const ctx = mkCtx(baseProps);
|
||||
const t0 = process.hrtime.bigint();
|
||||
for (let r = 0; r < rows; r++) {
|
||||
ctx.memo.clear();
|
||||
evaluate(tableAst, cells, ctx);
|
||||
}
|
||||
const ms = Number(process.hrtime.bigint() - t0) / 1e6;
|
||||
console.log(
|
||||
` ${fmt(rows).padStart(6)} rows -> ${ms.toFixed(1)} ms (${fmt((rows / ms) * 1000)} rows/sec)`,
|
||||
);
|
||||
}
|
||||
console.log();
|
||||
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "@docmost/base-formula",
|
||||
"homepage": "https://docmost.com",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "tsc --build",
|
||||
"dev": "tsc --watch",
|
||||
"bench": "tsx bench/formula-bench.ts"
|
||||
},
|
||||
"main": "dist/index.server.js",
|
||||
"module": "./dist/index.server.js",
|
||||
"exports": {
|
||||
"./client": {
|
||||
"types": "./dist/index.client.d.ts",
|
||||
"default": "./src/index.client.ts"
|
||||
},
|
||||
"./server": {
|
||||
"types": "./dist/index.server.d.ts",
|
||||
"default": "./dist/index.server.js"
|
||||
},
|
||||
".": {
|
||||
"types": "./dist/index.server.d.ts",
|
||||
"default": "./dist/index.server.js"
|
||||
}
|
||||
},
|
||||
"types": "dist/index.server.d.ts",
|
||||
"dependencies": {}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
export type OpCode =
|
||||
| "+" | "-" | "*" | "/" | "%"
|
||||
| "==" | "!=" | ">" | "<" | ">=" | "<="
|
||||
| "neg" | "not";
|
||||
|
||||
export type FormulaAST =
|
||||
| { t: "num"; v: number }
|
||||
| { t: "str"; v: string }
|
||||
| { t: "bool"; v: boolean }
|
||||
| { t: "null" }
|
||||
| { t: "prop"; id: string }
|
||||
| { t: "op"; op: OpCode; args: FormulaAST[] }
|
||||
| { t: "if"; cond: FormulaAST; then: FormulaAST; else: FormulaAST }
|
||||
| { t: "and"; args: FormulaAST[] }
|
||||
| { t: "or"; args: FormulaAST[] }
|
||||
| { t: "call"; fn: string; args: FormulaAST[] };
|
||||
|
||||
/*
|
||||
* Raw AST: what the parser produces before resolving property names to IDs.
|
||||
* Only the `propName` variant differs from FormulaAST — every other node is
|
||||
* reused directly. We deliberately keep this type-level to avoid duplicating
|
||||
* the tree shape.
|
||||
*/
|
||||
export type RawFormulaAST =
|
||||
| Exclude<FormulaAST, { t: "prop" }>
|
||||
| { t: "propName"; name: string }
|
||||
| { t: "op"; op: OpCode; args: RawFormulaAST[] }
|
||||
| { t: "if"; cond: RawFormulaAST; then: RawFormulaAST; else: RawFormulaAST }
|
||||
| { t: "and"; args: RawFormulaAST[] }
|
||||
| { t: "or"; args: RawFormulaAST[] }
|
||||
| { t: "call"; fn: string; args: RawFormulaAST[] };
|
||||
|
||||
export const AST_VERSION = 1 as const;
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { ErrorCell, ErrorCode } from "./types";
|
||||
|
||||
export type ParseErrorCode =
|
||||
| "UNEXPECTED_TOKEN"
|
||||
| "UNEXPECTED_EOF"
|
||||
| "UNKNOWN_PROPERTY"
|
||||
| "UNKNOWN_FUNCTION"
|
||||
| "ARITY_MISMATCH"
|
||||
| "TYPE_MISMATCH"
|
||||
| "CYCLE"
|
||||
| "INPUT_TOO_LONG"
|
||||
| "DEPTH_EXCEEDED";
|
||||
|
||||
export type ParseError = {
|
||||
code: ParseErrorCode;
|
||||
message: string;
|
||||
span: { start: number; end: number };
|
||||
hint?: string;
|
||||
};
|
||||
|
||||
export class FormulaParseError extends Error {
|
||||
readonly errors: ParseError[];
|
||||
constructor(errors: ParseError[]) {
|
||||
super(errors.map((e) => `${e.code}: ${e.message}`).join("; "));
|
||||
this.errors = errors;
|
||||
this.name = "FormulaParseError";
|
||||
}
|
||||
}
|
||||
|
||||
export function makeErrorCell(code: ErrorCode, msg: string): ErrorCell {
|
||||
return { __err: code, msg, v: 1 };
|
||||
}
|
||||
|
||||
export function isErrorCell(v: unknown): v is ErrorCell {
|
||||
return (
|
||||
typeof v === "object" &&
|
||||
v !== null &&
|
||||
"__err" in v &&
|
||||
typeof (v as { __err: unknown }).__err === "string"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import { makeErrorCell, isErrorCell } from "./error";
|
||||
import { valueToString } from "./number";
|
||||
import { MAX_EVAL_DEPTH } from "./types";
|
||||
import type { FormulaAST, OpCode } from "./ast";
|
||||
import type { Value, EvalContext } from "./types";
|
||||
|
||||
export function evaluate(
|
||||
ast: FormulaAST,
|
||||
row: Record<string, unknown>,
|
||||
ctx: EvalContext,
|
||||
astDepth = 0,
|
||||
): Value {
|
||||
// astDepth bounds AST tree-walk recursion (guards a hand-crafted deep
|
||||
// typeOptions.ast); ctx.depth separately bounds nested-formula hops.
|
||||
const depth = astDepth + 1;
|
||||
if (depth > MAX_EVAL_DEPTH) {
|
||||
return makeErrorCell("DEPTH_EXCEEDED", `formula too deeply nested (max ${MAX_EVAL_DEPTH})`);
|
||||
}
|
||||
|
||||
switch (ast.t) {
|
||||
case "num": return ast.v;
|
||||
case "str": return ast.v;
|
||||
case "bool": return ast.v;
|
||||
case "null": return null;
|
||||
case "prop": return evalProp(ast.id, row, ctx, depth);
|
||||
case "op": return evalOp(ast.op, ast.args, row, ctx, depth);
|
||||
case "if": {
|
||||
const c = evaluate(ast.cond, row, ctx, depth);
|
||||
if (isErrorCell(c)) return c;
|
||||
return evaluate(c === true ? ast.then : ast.else, row, ctx, depth);
|
||||
}
|
||||
case "and": {
|
||||
const xs = ast.args;
|
||||
for (let i = 0; i < xs.length; i++) {
|
||||
const v = evaluate(xs[i], row, ctx, depth);
|
||||
if (isErrorCell(v)) return v;
|
||||
if (v === false) return false;
|
||||
if (v == null) return null;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
case "or": {
|
||||
const xs = ast.args;
|
||||
for (let i = 0; i < xs.length; i++) {
|
||||
const v = evaluate(xs[i], row, ctx, depth);
|
||||
if (isErrorCell(v)) return v;
|
||||
if (v === true) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
case "call": {
|
||||
const fn = ctx.registry.get(ast.fn.toLowerCase());
|
||||
if (!fn) return makeErrorCell("MISSING_PROP", `unknown function ${ast.fn}`);
|
||||
const xs = ast.args;
|
||||
const args: Value[] = new Array(xs.length);
|
||||
for (let i = 0; i < xs.length; i++) {
|
||||
const v = evaluate(xs[i], row, ctx, depth);
|
||||
if (isErrorCell(v)) return { ...v, __err: "DEPENDENCY_ERROR" };
|
||||
args[i] = v;
|
||||
}
|
||||
try { return fn.eval(args, ctx); }
|
||||
catch (e) { return makeErrorCell("TYPE_MISMATCH", (e as Error).message); }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function evalProp(id: string, row: Record<string, unknown>, ctx: EvalContext, astDepth: number): Value {
|
||||
if (ctx.memo.has(id)) return ctx.memo.get(id)!;
|
||||
const prop = ctx.properties.get(id);
|
||||
if (!prop) return makeErrorCell("MISSING_PROP", `missing property ${id}`);
|
||||
if (prop.type !== "formula") return normalize(row[id] ?? null);
|
||||
// astDepth continues (not reset) across the nested-formula boundary.
|
||||
if (ctx.depth >= ctx.maxDepth) return makeErrorCell("DEPTH_EXCEEDED", `max depth ${ctx.maxDepth}`);
|
||||
const opts: any = prop.typeOptions;
|
||||
const nested: EvalContext = { ...ctx, depth: ctx.depth + 1, memo: ctx.memo };
|
||||
const v = evaluate(opts.ast, row, nested, astDepth);
|
||||
ctx.memo.set(id, v);
|
||||
return v;
|
||||
}
|
||||
|
||||
function normalize(v: unknown): Value {
|
||||
if (v === undefined) return null;
|
||||
if (v === null) return null;
|
||||
if (typeof v === "number" || typeof v === "string" || typeof v === "boolean") return v;
|
||||
if (isErrorCell(v)) return v;
|
||||
return null;
|
||||
}
|
||||
|
||||
function evalOp(
|
||||
op: OpCode,
|
||||
args: FormulaAST[],
|
||||
row: Record<string, unknown>,
|
||||
ctx: EvalContext,
|
||||
astDepth: number,
|
||||
): Value {
|
||||
const a = evaluate(args[0], row, ctx, astDepth);
|
||||
if (isErrorCell(a)) return { ...a, __err: "DEPENDENCY_ERROR" };
|
||||
if (op === "neg") return a == null ? null : -Number(a);
|
||||
if (op === "not") return a == null ? null : !Boolean(a);
|
||||
|
||||
const b = evaluate(args[1], row, ctx, astDepth);
|
||||
if (isErrorCell(b)) return { ...b, __err: "DEPENDENCY_ERROR" };
|
||||
|
||||
switch (op as Exclude<OpCode, "neg" | "not">) {
|
||||
case "+":
|
||||
if (typeof a === "string" || typeof b === "string") return valueToString(a) + valueToString(b);
|
||||
if (a == null || b == null) return null;
|
||||
return Number(a) + Number(b);
|
||||
case "-": return a == null || b == null ? null : Number(a) - Number(b);
|
||||
case "*": return a == null || b == null ? null : Number(a) * Number(b);
|
||||
case "/":
|
||||
if (a == null || b == null) return null;
|
||||
if (Number(b) === 0) return makeErrorCell("DIV_BY_ZERO", "division by zero");
|
||||
return Number(a) / Number(b);
|
||||
case "%":
|
||||
if (a == null || b == null) return null;
|
||||
if (Number(b) === 0) return makeErrorCell("DIV_BY_ZERO", "modulo by zero");
|
||||
return Number(a) % Number(b);
|
||||
case "==": return a === b;
|
||||
case "!=": return a !== b;
|
||||
case ">": return a != null && b != null && (a as any) > (b as any);
|
||||
case "<": return a != null && b != null && (a as any) < (b as any);
|
||||
case ">=": return a != null && b != null && (a as any) >= (b as any);
|
||||
case "<=": return a != null && b != null && (a as any) <= (b as any);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { FormulaAST, OpCode } from "./ast";
|
||||
|
||||
const OP_STR: Partial<Record<OpCode, string>> = {
|
||||
"+": " + ", "-": " - ", "*": " * ", "/": " / ", "%": " % ",
|
||||
"==": " == ", "!=": " != ", ">": " > ", "<": " < ", ">=": " >= ", "<=": " <= ",
|
||||
};
|
||||
|
||||
export function format(
|
||||
ast: FormulaAST,
|
||||
idToName: ReadonlyMap<string, string>,
|
||||
): string {
|
||||
switch (ast.t) {
|
||||
case "num": return String(ast.v);
|
||||
case "str": return `"${ast.v.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
|
||||
case "bool": return ast.v ? "true" : "false";
|
||||
case "null": return "null";
|
||||
case "prop": return `prop("${idToName.get(ast.id) ?? ast.id}")`;
|
||||
case "op":
|
||||
if (ast.op === "neg") return `-${format(ast.args[0], idToName)}`;
|
||||
if (ast.op === "not") return `not ${format(ast.args[0], idToName)}`;
|
||||
return `(${format(ast.args[0], idToName)}${OP_STR[ast.op]}${format(ast.args[1], idToName)})`;
|
||||
case "if":
|
||||
return `if(${format(ast.cond, idToName)}, ${format(ast.then, idToName)}, ${format(ast.else, idToName)})`;
|
||||
case "and":
|
||||
return `(${ast.args.map((a) => format(a, idToName)).join(" and ")})`;
|
||||
case "or":
|
||||
return `(${ast.args.map((a) => format(a, idToName)).join(" or ")})`;
|
||||
case "call":
|
||||
return `${ast.fn}(${ast.args.map((a) => format(a, idToName)).join(", ")})`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { register } from "./registry";
|
||||
import { valueToString } from "../number";
|
||||
|
||||
register({
|
||||
name: "toNumber", arity: { min: 1, max: 1 }, paramTypes: "any", returnType: "number",
|
||||
eval: ([v]) => {
|
||||
if (v == null) return null;
|
||||
const n = Number(v);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
},
|
||||
doc: "Parses the value as a number, or null.", category: "coercion",
|
||||
});
|
||||
register({
|
||||
name: "toString", arity: { min: 1, max: 1 }, paramTypes: "any", returnType: "string",
|
||||
eval: ([v]) => valueToString(v),
|
||||
doc: "Converts the value to a string.", category: "coercion",
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
import { register } from "./registry";
|
||||
import { makeErrorCell } from "../error";
|
||||
|
||||
const toDate = (v: unknown): Date | null => {
|
||||
if (v == null) return null;
|
||||
const d = new Date(String(v));
|
||||
return isNaN(d.getTime()) ? null : d;
|
||||
};
|
||||
|
||||
register({
|
||||
name: "now", arity: { min: 0, max: 0 }, paramTypes: [], returnType: "date",
|
||||
eval: () => new Date().toISOString(),
|
||||
doc: "Current timestamp.", category: "date",
|
||||
});
|
||||
register({
|
||||
name: "today", arity: { min: 0, max: 0 }, paramTypes: [], returnType: "date",
|
||||
eval: () => {
|
||||
const d = new Date(); d.setUTCHours(0, 0, 0, 0); return d.toISOString();
|
||||
},
|
||||
doc: "Midnight UTC of today.", category: "date",
|
||||
});
|
||||
register({
|
||||
name: "dateAdd", arity: { min: 3, max: 3 }, paramTypes: ["date", "number", "string"], returnType: "date",
|
||||
eval: ([base, amt, unit]) => {
|
||||
const d = toDate(base);
|
||||
if (!d) return makeErrorCell("DATE_INVALID", "invalid date");
|
||||
const n = Number(amt);
|
||||
const u = String(unit);
|
||||
const r = new Date(d);
|
||||
if (u === "days") r.setUTCDate(r.getUTCDate() + n);
|
||||
else if (u === "hours") r.setUTCHours(r.getUTCHours() + n);
|
||||
else if (u === "minutes") r.setUTCMinutes(r.getUTCMinutes() + n);
|
||||
else if (u === "months") r.setUTCMonth(r.getUTCMonth() + n);
|
||||
else if (u === "years") r.setUTCFullYear(r.getUTCFullYear() + n);
|
||||
else return makeErrorCell("TYPE_MISMATCH", `unknown unit ${u}`);
|
||||
return r.toISOString();
|
||||
},
|
||||
doc: "Adds a duration to a date. Units: days, hours, minutes, months, years.", category: "date",
|
||||
});
|
||||
register({
|
||||
name: "dateBetween", arity: { min: 3, max: 3 }, paramTypes: ["date", "date", "string"], returnType: "number",
|
||||
eval: ([a, b, unit]) => {
|
||||
const da = toDate(a), db = toDate(b);
|
||||
if (!da || !db) return makeErrorCell("DATE_INVALID", "invalid date");
|
||||
const ms = db.getTime() - da.getTime();
|
||||
const u = String(unit);
|
||||
if (u === "days") return Math.floor(ms / 86_400_000);
|
||||
if (u === "hours") return Math.floor(ms / 3_600_000);
|
||||
if (u === "minutes") return Math.floor(ms / 60_000);
|
||||
return makeErrorCell("TYPE_MISMATCH", `unknown unit ${u}`);
|
||||
},
|
||||
doc: "Difference between two dates in a given unit.", category: "date",
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
import "./logic";
|
||||
import "./math";
|
||||
import "./string";
|
||||
import "./date";
|
||||
import "./coercion";
|
||||
export { registry, register } from "./registry";
|
||||
export type { FormulaFn } from "./registry";
|
||||
@@ -0,0 +1,11 @@
|
||||
import { register } from "./registry";
|
||||
|
||||
register({
|
||||
name: "empty",
|
||||
arity: { min: 1, max: 1 },
|
||||
paramTypes: "any",
|
||||
returnType: "boolean",
|
||||
eval: ([v]) => v == null || v === "" || (typeof v === "object" && v !== null && "__err" in v),
|
||||
doc: "Returns true if the value is null or empty string or an error.",
|
||||
category: "logic",
|
||||
});
|
||||
@@ -0,0 +1,160 @@
|
||||
import { register } from "./registry";
|
||||
import { makeErrorCell } from "../error";
|
||||
import type { Value } from "../types";
|
||||
|
||||
const num = (v: unknown): number | null => v == null ? null : Number(v);
|
||||
|
||||
register({
|
||||
name: "round", arity: { min: 1, max: 2 }, paramTypes: ["number", "number"], returnType: "number",
|
||||
eval: ([v, places]) => {
|
||||
const n = num(v);
|
||||
if (n == null) return null;
|
||||
const p = places == null ? 0 : Math.trunc(Number(places));
|
||||
const factor = Math.pow(10, p);
|
||||
return Math.round(n * factor) / factor;
|
||||
},
|
||||
doc: "Rounds to the nearest integer, or to `places` decimals if given.", category: "math",
|
||||
});
|
||||
register({
|
||||
name: "floor", arity: { min: 1, max: 1 }, paramTypes: ["number"], returnType: "number",
|
||||
eval: ([v]) => { const n = num(v); return n == null ? null : Math.floor(n); },
|
||||
doc: "Rounds down.", category: "math",
|
||||
});
|
||||
register({
|
||||
name: "ceil", arity: { min: 1, max: 1 }, paramTypes: ["number"], returnType: "number",
|
||||
eval: ([v]) => { const n = num(v); return n == null ? null : Math.ceil(n); },
|
||||
doc: "Rounds up.", category: "math",
|
||||
});
|
||||
register({
|
||||
name: "abs", arity: { min: 1, max: 1 }, paramTypes: ["number"], returnType: "number",
|
||||
eval: ([v]) => { const n = num(v); return n == null ? null : Math.abs(n); },
|
||||
doc: "Absolute value.", category: "math",
|
||||
});
|
||||
register({
|
||||
name: "min", arity: { min: 1, max: null }, paramTypes: "variadic-any", returnType: "number",
|
||||
eval: (args) => {
|
||||
const nums = args.map(num).filter((n): n is number => n != null);
|
||||
return nums.length ? Math.min(...nums) : null;
|
||||
},
|
||||
doc: "Minimum of the arguments.", category: "math",
|
||||
});
|
||||
register({
|
||||
name: "max", arity: { min: 1, max: null }, paramTypes: "variadic-any", returnType: "number",
|
||||
eval: (args) => {
|
||||
const nums = args.map(num).filter((n): n is number => n != null);
|
||||
return nums.length ? Math.max(...nums) : null;
|
||||
},
|
||||
doc: "Maximum of the arguments.", category: "math",
|
||||
});
|
||||
register({
|
||||
name: "mod", arity: { min: 2, max: 2 }, paramTypes: ["number", "number"], returnType: "number",
|
||||
eval: ([a, b]) => {
|
||||
const na = num(a), nb = num(b);
|
||||
if (na == null || nb == null) return null;
|
||||
if (nb === 0) return makeErrorCell("DIV_BY_ZERO", "modulo by zero");
|
||||
return na % nb;
|
||||
},
|
||||
doc: "Remainder after division.", category: "math",
|
||||
});
|
||||
register({
|
||||
name: "add", arity: { min: 2, max: 2 }, paramTypes: ["number", "number"], returnType: "number",
|
||||
eval: ([a, b]) => {
|
||||
const na = num(a), nb = num(b);
|
||||
return na == null || nb == null ? null : na + nb;
|
||||
},
|
||||
doc: "Sum of two numbers.", category: "math",
|
||||
});
|
||||
register({
|
||||
name: "subtract", arity: { min: 2, max: 2 }, paramTypes: ["number", "number"], returnType: "number",
|
||||
eval: ([a, b]) => {
|
||||
const na = num(a), nb = num(b);
|
||||
return na == null || nb == null ? null : na - nb;
|
||||
},
|
||||
doc: "Difference of two numbers.", category: "math",
|
||||
});
|
||||
register({
|
||||
name: "multiply", arity: { min: 2, max: 2 }, paramTypes: ["number", "number"], returnType: "number",
|
||||
eval: ([a, b]) => {
|
||||
const na = num(a), nb = num(b);
|
||||
return na == null || nb == null ? null : na * nb;
|
||||
},
|
||||
doc: "Product of two numbers.", category: "math",
|
||||
});
|
||||
register({
|
||||
name: "divide", arity: { min: 2, max: 2 }, paramTypes: ["number", "number"], returnType: "number",
|
||||
eval: ([a, b]) => {
|
||||
const na = num(a), nb = num(b);
|
||||
if (na == null || nb == null) return null;
|
||||
if (nb === 0) return makeErrorCell("DIV_BY_ZERO", "division by zero");
|
||||
return na / nb;
|
||||
},
|
||||
doc: "Quotient of two numbers.", category: "math",
|
||||
});
|
||||
register({
|
||||
name: "pow", arity: { min: 2, max: 2 }, paramTypes: ["number", "number"], returnType: "number",
|
||||
eval: ([a, b]) => {
|
||||
const na = num(a), nb = num(b);
|
||||
return na == null || nb == null ? null : Math.pow(na, nb);
|
||||
},
|
||||
doc: "Base raised to an exponent.", category: "math",
|
||||
});
|
||||
register({
|
||||
name: "sqrt", arity: { min: 1, max: 1 }, paramTypes: ["number"], returnType: "number",
|
||||
eval: ([v]) => {
|
||||
const n = num(v);
|
||||
if (n == null) return null;
|
||||
if (n < 0) return makeErrorCell("TYPE_MISMATCH", "sqrt of negative number");
|
||||
return Math.sqrt(n);
|
||||
},
|
||||
doc: "Positive square root.", category: "math",
|
||||
});
|
||||
register({
|
||||
name: "sum", arity: { min: 1, max: null }, paramTypes: "variadic-any", returnType: "number",
|
||||
eval: (args) => {
|
||||
// Null propagates as 0 so `sum(prop("A"), prop("B"))` still works when
|
||||
// some cells are empty — matches Airtable/Notion semantics.
|
||||
let total = 0;
|
||||
for (const v of args) {
|
||||
const n = num(v);
|
||||
if (n != null && Number.isFinite(n)) total += n;
|
||||
}
|
||||
return total;
|
||||
},
|
||||
doc: "Sum of the arguments.", category: "math",
|
||||
});
|
||||
const meanEval = (args: Value[]): Value => {
|
||||
const nums: number[] = [];
|
||||
for (const v of args) {
|
||||
const n = num(v);
|
||||
if (n != null && Number.isFinite(n)) nums.push(n);
|
||||
}
|
||||
if (nums.length === 0) return null;
|
||||
return nums.reduce((a, b) => a + b, 0) / nums.length;
|
||||
};
|
||||
register({
|
||||
name: "mean", arity: { min: 1, max: null }, paramTypes: "variadic-any", returnType: "number",
|
||||
eval: meanEval,
|
||||
doc: "Arithmetic average of the arguments.", category: "math",
|
||||
});
|
||||
register({
|
||||
name: "average", arity: { min: 1, max: null }, paramTypes: "variadic-any", returnType: "number",
|
||||
eval: meanEval,
|
||||
doc: "Arithmetic average of the arguments (alias of mean).", category: "math",
|
||||
});
|
||||
register({
|
||||
name: "median", arity: { min: 1, max: null }, paramTypes: "variadic-any", returnType: "number",
|
||||
eval: (args) => {
|
||||
const nums: number[] = [];
|
||||
for (const v of args) {
|
||||
const n = num(v);
|
||||
if (n != null && Number.isFinite(n)) nums.push(n);
|
||||
}
|
||||
if (nums.length === 0) return null;
|
||||
nums.sort((a, b) => a - b);
|
||||
const mid = Math.floor(nums.length / 2);
|
||||
return nums.length % 2 === 0
|
||||
? (nums[mid - 1] + nums[mid]) / 2
|
||||
: nums[mid];
|
||||
},
|
||||
doc: "Middle value of the arguments.", category: "math",
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { FormulaResultType, Value, EvalContext } from "../types";
|
||||
|
||||
export type FormulaFn = {
|
||||
name: string;
|
||||
arity: { min: number; max: number | null };
|
||||
paramTypes: FormulaResultType[] | "any" | "variadic-any";
|
||||
returnType: FormulaResultType | ((argTypes: FormulaResultType[]) => FormulaResultType);
|
||||
eval: (args: Value[], ctx: EvalContext) => Value;
|
||||
doc: string;
|
||||
category: "logic" | "math" | "string" | "date" | "coercion";
|
||||
};
|
||||
|
||||
export const registry: Map<string, FormulaFn> = new Map();
|
||||
|
||||
export function register(fn: FormulaFn): void {
|
||||
// Functions are looked up case-insensitively (see eval/typecheck), so the
|
||||
// registry is keyed by the lowercased name. fn.name keeps its canonical
|
||||
// casing for display in the function picker and `format()`.
|
||||
const key = fn.name.toLowerCase();
|
||||
if (registry.has(key)) {
|
||||
throw new Error(`Duplicate formula function: ${fn.name}`);
|
||||
}
|
||||
registry.set(key, fn);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { register } from "./registry";
|
||||
import { valueToString } from "../number";
|
||||
|
||||
const s = (v: unknown): string => valueToString(v);
|
||||
|
||||
register({
|
||||
name: "concat", arity: { min: 1, max: null }, paramTypes: "variadic-any", returnType: "string",
|
||||
eval: (args) => args.map(s).join(""),
|
||||
doc: "Concatenates strings.", category: "string",
|
||||
});
|
||||
register({
|
||||
name: "length", arity: { min: 1, max: 1 }, paramTypes: ["string"], returnType: "number",
|
||||
eval: ([v]) => s(v).length,
|
||||
doc: "Length of a string.", category: "string",
|
||||
});
|
||||
register({
|
||||
name: "contains", arity: { min: 2, max: 2 }, paramTypes: ["string", "string"], returnType: "boolean",
|
||||
eval: ([a, b]) => s(a).includes(s(b)),
|
||||
doc: "Returns true if the first string contains the second.", category: "string",
|
||||
});
|
||||
register({
|
||||
name: "lower", arity: { min: 1, max: 1 }, paramTypes: ["string"], returnType: "string",
|
||||
eval: ([v]) => s(v).toLowerCase(),
|
||||
doc: "Lowercases the string.", category: "string",
|
||||
});
|
||||
register({
|
||||
name: "upper", arity: { min: 1, max: 1 }, paramTypes: ["string"], returnType: "string",
|
||||
eval: ([v]) => s(v).toUpperCase(),
|
||||
doc: "Uppercases the string.", category: "string",
|
||||
});
|
||||
register({
|
||||
name: "trim", arity: { min: 1, max: 1 }, paramTypes: ["string"], returnType: "string",
|
||||
eval: ([v]) => s(v).trim(),
|
||||
doc: "Strips whitespace from both ends.", category: "string",
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
type PropLike = { id: string; type: string; typeOptions: unknown };
|
||||
|
||||
export class BaseFormulaGraph {
|
||||
private readonly direct = new Map<string, string[]>();
|
||||
private readonly reverse = new Map<string, Set<string>>();
|
||||
|
||||
constructor(properties: PropLike[]) {
|
||||
for (const p of properties) {
|
||||
if (p.type !== "formula") continue;
|
||||
const deps: string[] = Array.isArray((p.typeOptions as any)?.dependencies)
|
||||
? ((p.typeOptions as any).dependencies as string[])
|
||||
: [];
|
||||
this.direct.set(p.id, deps);
|
||||
for (const d of deps) {
|
||||
if (!this.reverse.has(d)) this.reverse.set(d, new Set());
|
||||
this.reverse.get(d)!.add(p.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
directDeps(propId: string): string[] { return this.direct.get(propId) ?? []; }
|
||||
|
||||
dependents(propId: string): string[] { return Array.from(this.reverse.get(propId) ?? []); }
|
||||
|
||||
affectedFormulas(changedPropIds: string[]): string[] {
|
||||
const out = new Set<string>();
|
||||
const stack = [...changedPropIds];
|
||||
while (stack.length) {
|
||||
const id = stack.pop()!;
|
||||
for (const d of this.reverse.get(id) ?? []) {
|
||||
if (!out.has(d)) { out.add(d); stack.push(d); }
|
||||
}
|
||||
}
|
||||
return Array.from(out).sort();
|
||||
}
|
||||
|
||||
evalOrder(): string[] {
|
||||
const order: string[] = [];
|
||||
const visited = new Set<string>();
|
||||
const temp = new Set<string>();
|
||||
const visit = (id: string) => {
|
||||
if (visited.has(id)) return;
|
||||
if (temp.has(id)) return;
|
||||
temp.add(id);
|
||||
for (const d of this.direct.get(id) ?? []) visit(d);
|
||||
temp.delete(id);
|
||||
visited.add(id);
|
||||
order.push(id);
|
||||
};
|
||||
for (const id of this.direct.keys()) visit(id);
|
||||
return order;
|
||||
}
|
||||
|
||||
/*
|
||||
* Returns the cycle path (list of prop IDs) if introducing `newProp` (or
|
||||
* keeping its current deps) would create one, else null. `newProp` may be
|
||||
* either a property already registered or a hypothetical replacement; we
|
||||
* re-read its deps at call time, so pass the candidate object.
|
||||
*/
|
||||
detectCycle(newProp: PropLike): string[] | null {
|
||||
const local = new Map(this.direct);
|
||||
if (newProp.type === "formula") {
|
||||
local.set(newProp.id, (newProp.typeOptions as any)?.dependencies ?? []);
|
||||
}
|
||||
const WHITE = 0, GRAY = 1, BLACK = 2;
|
||||
const color = new Map<string, number>();
|
||||
const path: string[] = [];
|
||||
const dfs = (id: string): string[] | null => {
|
||||
color.set(id, GRAY);
|
||||
path.push(id);
|
||||
for (const d of local.get(id) ?? []) {
|
||||
const c = color.get(d) ?? WHITE;
|
||||
if (c === GRAY) { return [...path.slice(path.indexOf(d)), d]; }
|
||||
if (c === WHITE) { const r = dfs(d); if (r) return r; }
|
||||
}
|
||||
path.pop();
|
||||
color.set(id, BLACK);
|
||||
return null;
|
||||
};
|
||||
for (const id of local.keys()) {
|
||||
if ((color.get(id) ?? WHITE) === WHITE) {
|
||||
const r = dfs(id);
|
||||
if (r) return r;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
// Client-side public surface: parse, typecheck, cycle-detect, pretty-print.
|
||||
import "./functions/index";
|
||||
export * from "./ast";
|
||||
export * from "./types";
|
||||
export * from "./error";
|
||||
export * from "./tokenizer";
|
||||
export * from "./parser";
|
||||
export * from "./resolver";
|
||||
export * from "./typecheck";
|
||||
export * from "./format";
|
||||
export { registry, register } from "./functions/registry";
|
||||
export type { FormulaFn } from "./functions/registry";
|
||||
export * from "./graph";
|
||||
export * from "./number";
|
||||
@@ -0,0 +1,15 @@
|
||||
// Server-side public surface: everything in client + evaluator + registry.
|
||||
export * from "./ast";
|
||||
export * from "./types";
|
||||
export * from "./error";
|
||||
export * from "./tokenizer";
|
||||
export * from "./parser";
|
||||
export * from "./resolver";
|
||||
export * from "./typecheck";
|
||||
export * from "./format";
|
||||
import "./functions/index"; // side-effect: populate registry
|
||||
export { registry, register } from "./functions/index";
|
||||
export type { FormulaFn } from "./functions/index";
|
||||
export * from "./graph";
|
||||
export * from "./eval";
|
||||
export * from "./number";
|
||||
@@ -0,0 +1,10 @@
|
||||
export function snapNumber(n: number): number {
|
||||
if (!Number.isFinite(n)) return n;
|
||||
return Number(n.toPrecision(15));
|
||||
}
|
||||
|
||||
export function valueToString(v: unknown): string {
|
||||
if (v == null) return "";
|
||||
if (typeof v === "number") return String(snapNumber(v));
|
||||
return String(v);
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
import { tokenize, Token, TokenKind } from "./tokenizer";
|
||||
import { FormulaParseError } from "./error";
|
||||
import { MAX_PARSE_DEPTH } from "./types";
|
||||
import type { OpCode } from "./ast";
|
||||
import type { RawFormulaAST } from "./ast";
|
||||
|
||||
/*
|
||||
* Pratt parser. Top-level entry parses a full expression and then asserts EOF.
|
||||
* Binary operators are dispatched through a precedence table in `bp` below.
|
||||
* `prop(...)`, `if(...)`, `and(...)`, `or(...)` are intercepted when an
|
||||
* identifier is followed by `(` so they become their dedicated AST nodes.
|
||||
*/
|
||||
export function parseRaw(src: string): RawFormulaAST {
|
||||
const tokens = tokenize(src);
|
||||
const p = new Parser(tokens);
|
||||
const expr = p.parseExpr(0);
|
||||
p.expect(TokenKind.EOF, "Expected end of input");
|
||||
return expr;
|
||||
}
|
||||
|
||||
const BP: Partial<Record<TokenKind, number>> = {
|
||||
[TokenKind.OR]: 10,
|
||||
[TokenKind.AND]: 20,
|
||||
[TokenKind.EQ]: 30, [TokenKind.NEQ]: 30,
|
||||
[TokenKind.LT]: 40, [TokenKind.GT]: 40,
|
||||
[TokenKind.LTE]: 40, [TokenKind.GTE]: 40,
|
||||
[TokenKind.PLUS]: 50, [TokenKind.MINUS]: 50,
|
||||
[TokenKind.STAR]: 60, [TokenKind.SLASH]: 60, [TokenKind.PERCENT]: 60,
|
||||
};
|
||||
|
||||
const TOK_TO_OP: Partial<Record<TokenKind, OpCode>> = {
|
||||
[TokenKind.PLUS]: "+", [TokenKind.MINUS]: "-",
|
||||
[TokenKind.STAR]: "*", [TokenKind.SLASH]: "/", [TokenKind.PERCENT]: "%",
|
||||
[TokenKind.EQ]: "==", [TokenKind.NEQ]: "!=",
|
||||
[TokenKind.LT]: "<", [TokenKind.GT]: ">",
|
||||
[TokenKind.LTE]: "<=", [TokenKind.GTE]: ">=",
|
||||
};
|
||||
|
||||
class Parser {
|
||||
private i = 0;
|
||||
private depth = 0;
|
||||
constructor(private tokens: Token[]) {}
|
||||
|
||||
peek(): Token { return this.tokens[this.i]; }
|
||||
next(): Token { return this.tokens[this.i++]; }
|
||||
expect(kind: TokenKind, msg: string): Token {
|
||||
const t = this.peek();
|
||||
if (t.kind !== kind) {
|
||||
throw new FormulaParseError([{
|
||||
code: "UNEXPECTED_TOKEN", message: msg, span: { start: t.start, end: t.end },
|
||||
}]);
|
||||
}
|
||||
return this.next();
|
||||
}
|
||||
|
||||
// Bound recursive descent. Every path that recurses (parens, unary chains,
|
||||
// binary rhs, call args) funnels through parseExpr/parseUnary, so guarding
|
||||
// their entry caps the JS stack and turns pathological nesting into a
|
||||
// catchable FormulaParseError instead of a RangeError.
|
||||
private enter(): void {
|
||||
if (++this.depth > MAX_PARSE_DEPTH) {
|
||||
const t = this.peek();
|
||||
throw new FormulaParseError([{
|
||||
code: "DEPTH_EXCEEDED",
|
||||
message: `Formula nesting too deep (max ${MAX_PARSE_DEPTH})`,
|
||||
span: { start: t.start, end: t.end },
|
||||
}]);
|
||||
}
|
||||
}
|
||||
|
||||
parseExpr(minBp: number): RawFormulaAST {
|
||||
this.enter();
|
||||
try {
|
||||
return this.parseExprInner(minBp);
|
||||
} finally {
|
||||
this.depth--;
|
||||
}
|
||||
}
|
||||
|
||||
private parseExprInner(minBp: number): RawFormulaAST {
|
||||
let lhs = this.parseUnary();
|
||||
|
||||
while (true) {
|
||||
const tok = this.peek();
|
||||
if (tok.kind === TokenKind.AND) {
|
||||
if (BP[TokenKind.AND]! < minBp) break;
|
||||
this.next();
|
||||
const rhs = this.parseExpr(BP[TokenKind.AND]! + 1);
|
||||
lhs = { t: "and", args: [lhs, rhs] };
|
||||
continue;
|
||||
}
|
||||
if (tok.kind === TokenKind.OR) {
|
||||
if (BP[TokenKind.OR]! < minBp) break;
|
||||
this.next();
|
||||
const rhs = this.parseExpr(BP[TokenKind.OR]! + 1);
|
||||
lhs = { t: "or", args: [lhs, rhs] };
|
||||
continue;
|
||||
}
|
||||
const bp = BP[tok.kind];
|
||||
if (bp == null || bp < minBp) break;
|
||||
this.next();
|
||||
const rhs = this.parseExpr(bp + 1);
|
||||
const op = TOK_TO_OP[tok.kind]!;
|
||||
lhs = { t: "op", op, args: [lhs, rhs] };
|
||||
}
|
||||
return lhs;
|
||||
}
|
||||
|
||||
parseUnary(): RawFormulaAST {
|
||||
const tok = this.peek();
|
||||
if (tok.kind === TokenKind.MINUS) {
|
||||
this.next();
|
||||
this.enter();
|
||||
try {
|
||||
return { t: "op", op: "neg", args: [this.parseUnary()] };
|
||||
} finally {
|
||||
this.depth--;
|
||||
}
|
||||
}
|
||||
if (tok.kind === TokenKind.NOT) {
|
||||
this.next();
|
||||
this.enter();
|
||||
try {
|
||||
return { t: "op", op: "not", args: [this.parseUnary()] };
|
||||
} finally {
|
||||
this.depth--;
|
||||
}
|
||||
}
|
||||
return this.parsePrimary();
|
||||
}
|
||||
|
||||
parsePrimary(): RawFormulaAST {
|
||||
const tok = this.next();
|
||||
switch (tok.kind) {
|
||||
case TokenKind.NUMBER: return { t: "num", v: Number(tok.text) };
|
||||
case TokenKind.STRING: return { t: "str", v: tok.text };
|
||||
case TokenKind.TRUE: return { t: "bool", v: true };
|
||||
case TokenKind.FALSE: return { t: "bool", v: false };
|
||||
case TokenKind.NULL: return { t: "null" };
|
||||
case TokenKind.LPAREN: {
|
||||
const e = this.parseExpr(0);
|
||||
this.expect(TokenKind.RPAREN, "Expected ')'");
|
||||
return e;
|
||||
}
|
||||
case TokenKind.AND:
|
||||
case TokenKind.OR:
|
||||
case TokenKind.IDENT: {
|
||||
if (this.peek().kind !== TokenKind.LPAREN) {
|
||||
throw new FormulaParseError([{
|
||||
code: "UNEXPECTED_TOKEN",
|
||||
message: `Unexpected identifier '${tok.text}' (did you mean prop("${tok.text}")?)`,
|
||||
span: { start: tok.start, end: tok.end },
|
||||
}]);
|
||||
}
|
||||
this.next(); // LPAREN
|
||||
const args: RawFormulaAST[] = [];
|
||||
if (this.peek().kind !== TokenKind.RPAREN) {
|
||||
args.push(this.parseExpr(0));
|
||||
while (this.peek().kind === TokenKind.COMMA) {
|
||||
this.next();
|
||||
args.push(this.parseExpr(0));
|
||||
}
|
||||
}
|
||||
this.expect(TokenKind.RPAREN, "Expected ')'");
|
||||
|
||||
// Match special-form/keyword names case-insensitively; the `call`
|
||||
// node below keeps the raw casing the user typed.
|
||||
const head = tok.text.toLowerCase();
|
||||
if (head === "prop") {
|
||||
if (args.length !== 1 || args[0].t !== "str") {
|
||||
throw new FormulaParseError([{
|
||||
code: "UNEXPECTED_TOKEN",
|
||||
message: 'prop() expects exactly one string literal argument',
|
||||
span: { start: tok.start, end: tok.end },
|
||||
}]);
|
||||
}
|
||||
return { t: "propName", name: args[0].v };
|
||||
}
|
||||
if (head === "if") {
|
||||
if (args.length !== 3) {
|
||||
throw new FormulaParseError([{
|
||||
code: "ARITY_MISMATCH",
|
||||
message: "if() expects exactly 3 arguments",
|
||||
span: { start: tok.start, end: tok.end },
|
||||
}]);
|
||||
}
|
||||
return { t: "if", cond: args[0], then: args[1], else: args[2] };
|
||||
}
|
||||
if (head === "and") return { t: "and", args };
|
||||
if (head === "or") return { t: "or", args };
|
||||
return { t: "call", fn: tok.text, args };
|
||||
}
|
||||
default:
|
||||
throw new FormulaParseError([{
|
||||
code: "UNEXPECTED_TOKEN",
|
||||
message: `Unexpected token '${tok.text || tok.kind}'`,
|
||||
span: { start: tok.start, end: tok.end },
|
||||
}]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { FormulaParseError } from "./error";
|
||||
import type { FormulaAST, RawFormulaAST } from "./ast";
|
||||
|
||||
export type ResolveResult = {
|
||||
ast: FormulaAST;
|
||||
dependencies: string[];
|
||||
};
|
||||
|
||||
export function resolve(
|
||||
raw: RawFormulaAST,
|
||||
nameToId: ReadonlyMap<string, string>,
|
||||
): ResolveResult {
|
||||
const deps = new Set<string>();
|
||||
const ast = walk(raw, nameToId, deps);
|
||||
return { ast, dependencies: Array.from(deps).sort() };
|
||||
}
|
||||
|
||||
function walk(
|
||||
node: RawFormulaAST,
|
||||
nameToId: ReadonlyMap<string, string>,
|
||||
deps: Set<string>,
|
||||
): FormulaAST {
|
||||
switch (node.t) {
|
||||
case "num": case "str": case "bool": case "null":
|
||||
return node as FormulaAST;
|
||||
case "propName": {
|
||||
const id = nameToId.get(node.name);
|
||||
if (!id) {
|
||||
throw new FormulaParseError([{
|
||||
code: "UNKNOWN_PROPERTY",
|
||||
message: `Unknown property '${node.name}'`,
|
||||
span: { start: 0, end: 0 }, // parser carries real spans; resolver is post-parse
|
||||
}]);
|
||||
}
|
||||
deps.add(id);
|
||||
return { t: "prop", id };
|
||||
}
|
||||
case "op":
|
||||
return {
|
||||
t: "op",
|
||||
op: (node as any).op,
|
||||
args: (node as any).args.map((a: RawFormulaAST) => walk(a, nameToId, deps)),
|
||||
};
|
||||
case "if":
|
||||
return {
|
||||
t: "if",
|
||||
cond: walk((node as any).cond, nameToId, deps),
|
||||
then: walk((node as any).then, nameToId, deps),
|
||||
else: walk((node as any).else, nameToId, deps),
|
||||
};
|
||||
case "and":
|
||||
return {
|
||||
t: "and",
|
||||
args: (node as any).args.map((a: RawFormulaAST) => walk(a, nameToId, deps)),
|
||||
};
|
||||
case "or":
|
||||
return {
|
||||
t: "or",
|
||||
args: (node as any).args.map((a: RawFormulaAST) => walk(a, nameToId, deps)),
|
||||
};
|
||||
case "call":
|
||||
return {
|
||||
t: "call",
|
||||
fn: (node as any).fn,
|
||||
args: (node as any).args.map((a: RawFormulaAST) => walk(a, nameToId, deps)),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
import { FormulaParseError } from "./error";
|
||||
import { MAX_FORMULA_SOURCE_LENGTH } from "./types";
|
||||
|
||||
export enum TokenKind {
|
||||
NUMBER = "NUMBER",
|
||||
STRING = "STRING",
|
||||
IDENT = "IDENT",
|
||||
TRUE = "TRUE",
|
||||
FALSE = "FALSE",
|
||||
NULL = "NULL",
|
||||
AND = "AND",
|
||||
OR = "OR",
|
||||
NOT = "NOT",
|
||||
PLUS = "PLUS",
|
||||
MINUS = "MINUS",
|
||||
STAR = "STAR",
|
||||
SLASH = "SLASH",
|
||||
PERCENT = "PERCENT",
|
||||
EQ = "EQ",
|
||||
NEQ = "NEQ",
|
||||
LT = "LT",
|
||||
GT = "GT",
|
||||
LTE = "LTE",
|
||||
GTE = "GTE",
|
||||
LPAREN = "LPAREN",
|
||||
RPAREN = "RPAREN",
|
||||
COMMA = "COMMA",
|
||||
EOF = "EOF",
|
||||
}
|
||||
|
||||
export type Token = {
|
||||
kind: TokenKind;
|
||||
text: string;
|
||||
start: number;
|
||||
end: number;
|
||||
};
|
||||
|
||||
const KEYWORDS: Record<string, TokenKind> = {
|
||||
true: TokenKind.TRUE,
|
||||
false: TokenKind.FALSE,
|
||||
null: TokenKind.NULL,
|
||||
and: TokenKind.AND,
|
||||
or: TokenKind.OR,
|
||||
not: TokenKind.NOT,
|
||||
};
|
||||
|
||||
export function tokenize(src: string): Token[] {
|
||||
if (src.length > MAX_FORMULA_SOURCE_LENGTH) {
|
||||
throw new FormulaParseError([{
|
||||
code: "INPUT_TOO_LONG",
|
||||
message: `Formula is too long (${src.length} chars; max ${MAX_FORMULA_SOURCE_LENGTH})`,
|
||||
span: { start: 0, end: MAX_FORMULA_SOURCE_LENGTH },
|
||||
}]);
|
||||
}
|
||||
const tokens: Token[] = [];
|
||||
let i = 0;
|
||||
|
||||
const push = (kind: TokenKind, text: string, start: number, end: number) =>
|
||||
tokens.push({ kind, text, start, end });
|
||||
|
||||
while (i < src.length) {
|
||||
const ch = src[i];
|
||||
if (ch === " " || ch === "\t" || ch === "\n" || ch === "\r") { i++; continue; }
|
||||
|
||||
if (ch >= "0" && ch <= "9") {
|
||||
const start = i;
|
||||
while (i < src.length && src[i] >= "0" && src[i] <= "9") i++;
|
||||
if (src[i] === ".") {
|
||||
i++;
|
||||
while (i < src.length && src[i] >= "0" && src[i] <= "9") i++;
|
||||
}
|
||||
push(TokenKind.NUMBER, src.slice(start, i), start, i);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ch === '"' || ch === "'") {
|
||||
const quote = ch;
|
||||
const start = i;
|
||||
i++;
|
||||
let body = "";
|
||||
while (i < src.length && src[i] !== quote) {
|
||||
if (src[i] === "\\") {
|
||||
if (i + 1 >= src.length) {
|
||||
throw new FormulaParseError([{
|
||||
code: "UNEXPECTED_EOF",
|
||||
message: "Unterminated escape in string",
|
||||
span: { start, end: i + 1 },
|
||||
}]);
|
||||
}
|
||||
const esc = src[i + 1];
|
||||
body += esc === "n" ? "\n" : esc === "t" ? "\t" : esc;
|
||||
i += 2;
|
||||
} else {
|
||||
body += src[i];
|
||||
i++;
|
||||
}
|
||||
}
|
||||
if (i >= src.length) {
|
||||
throw new FormulaParseError([{
|
||||
code: "UNEXPECTED_EOF",
|
||||
message: "Unterminated string literal",
|
||||
span: { start, end: src.length },
|
||||
}]);
|
||||
}
|
||||
i++;
|
||||
push(TokenKind.STRING, body, start, i);
|
||||
continue;
|
||||
}
|
||||
|
||||
if ((ch >= "a" && ch <= "z") || (ch >= "A" && ch <= "Z") || ch === "_") {
|
||||
const start = i;
|
||||
while (
|
||||
i < src.length &&
|
||||
(
|
||||
(src[i] >= "a" && src[i] <= "z") ||
|
||||
(src[i] >= "A" && src[i] <= "Z") ||
|
||||
(src[i] >= "0" && src[i] <= "9") ||
|
||||
src[i] === "_"
|
||||
)
|
||||
) i++;
|
||||
const text = src.slice(start, i);
|
||||
// Keywords and function names are case-insensitive: match on the
|
||||
// lowercased text but keep `text` raw on the token so error messages
|
||||
// and `format()` preserve the user's casing.
|
||||
// hasOwnProperty guards against inherited Object.prototype names
|
||||
// (toString, valueOf, hasOwnProperty, …) matching the KEYWORDS lookup —
|
||||
// those are valid identifiers/function names (e.g. the toString() fn).
|
||||
const lower = text.toLowerCase();
|
||||
const kw = Object.prototype.hasOwnProperty.call(KEYWORDS, lower) ? KEYWORDS[lower] : undefined;
|
||||
push(kw ?? TokenKind.IDENT, text, start, i);
|
||||
continue;
|
||||
}
|
||||
|
||||
const start = i;
|
||||
const two = src.slice(i, i + 2);
|
||||
if (two === "==") { push(TokenKind.EQ, two, start, i + 2); i += 2; continue; }
|
||||
if (two === "!=") { push(TokenKind.NEQ, two, start, i + 2); i += 2; continue; }
|
||||
if (two === "<=") { push(TokenKind.LTE, two, start, i + 2); i += 2; continue; }
|
||||
if (two === ">=") { push(TokenKind.GTE, two, start, i + 2); i += 2; continue; }
|
||||
|
||||
const singleMap: Record<string, TokenKind> = {
|
||||
"+": TokenKind.PLUS, "-": TokenKind.MINUS, "*": TokenKind.STAR,
|
||||
"/": TokenKind.SLASH, "%": TokenKind.PERCENT,
|
||||
"<": TokenKind.LT, ">": TokenKind.GT,
|
||||
"(": TokenKind.LPAREN, ")": TokenKind.RPAREN, ",": TokenKind.COMMA,
|
||||
};
|
||||
if (singleMap[ch]) { push(singleMap[ch], ch, start, i + 1); i++; continue; }
|
||||
|
||||
throw new FormulaParseError([{
|
||||
code: "UNEXPECTED_TOKEN",
|
||||
message: `Unexpected character '${ch}'`,
|
||||
span: { start: i, end: i + 1 },
|
||||
}]);
|
||||
}
|
||||
|
||||
tokens.push({ kind: TokenKind.EOF, text: "", start: i, end: i });
|
||||
return tokens;
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { FormulaParseError } from "./error";
|
||||
import type { FormulaAST, OpCode } from "./ast";
|
||||
import type { FormulaResultType } from "./types";
|
||||
import type { FormulaFn } from "./functions";
|
||||
|
||||
export type PropertyTypeMap = ReadonlyMap<string, FormulaResultType>;
|
||||
|
||||
export type TypecheckResult = { resultType: FormulaResultType };
|
||||
|
||||
const ARITH_OPS: OpCode[] = ["+", "-", "*", "/", "%"];
|
||||
const CMP_OPS: OpCode[] = ["==", "!=", ">", "<", ">=", "<="];
|
||||
|
||||
export function typecheck(
|
||||
ast: FormulaAST,
|
||||
propertyTypes: PropertyTypeMap,
|
||||
registry: ReadonlyMap<string, FormulaFn>,
|
||||
): TypecheckResult {
|
||||
return { resultType: infer(ast, propertyTypes, registry) };
|
||||
}
|
||||
|
||||
function infer(
|
||||
ast: FormulaAST,
|
||||
propertyTypes: PropertyTypeMap,
|
||||
registry: ReadonlyMap<string, FormulaFn>,
|
||||
): FormulaResultType {
|
||||
switch (ast.t) {
|
||||
case "num": return "number";
|
||||
case "str": return "string";
|
||||
case "bool": return "boolean";
|
||||
case "null": return "null";
|
||||
case "prop": return propertyTypes.get(ast.id) ?? "null";
|
||||
case "op": {
|
||||
const argTypes = ast.args.map((a) => infer(a, propertyTypes, registry));
|
||||
if (ARITH_OPS.includes(ast.op)) {
|
||||
// '+' is overloaded to match the evaluator: any string operand makes
|
||||
// it string concatenation; otherwise it's numeric addition.
|
||||
if (ast.op === "+" && argTypes.some((t) => t === "string")) return "string";
|
||||
const allow = argTypes.every((t) => t === "number" || t === "null");
|
||||
if (!allow) throw typeErr(`Operator '${ast.op}' needs numbers`);
|
||||
return "number";
|
||||
}
|
||||
if (CMP_OPS.includes(ast.op)) return "boolean";
|
||||
if (ast.op === "neg") {
|
||||
if (argTypes[0] !== "number" && argTypes[0] !== "null") throw typeErr("Unary '-' needs number");
|
||||
return "number";
|
||||
}
|
||||
if (ast.op === "not") {
|
||||
if (argTypes[0] !== "boolean" && argTypes[0] !== "null") throw typeErr("'not' needs boolean");
|
||||
return "boolean";
|
||||
}
|
||||
return "null";
|
||||
}
|
||||
case "if": {
|
||||
const thenT = infer(ast.then, propertyTypes, registry);
|
||||
const elseT = infer(ast.else, propertyTypes, registry);
|
||||
if (thenT === elseT) return thenT;
|
||||
if (thenT === "null") return elseT;
|
||||
if (elseT === "null") return thenT;
|
||||
throw typeErr(`if() branches have different types: ${thenT} vs ${elseT}`);
|
||||
}
|
||||
case "and": case "or":
|
||||
ast.args.forEach((a) => {
|
||||
const t = infer(a, propertyTypes, registry);
|
||||
if (t !== "boolean" && t !== "null") throw typeErr(`'${ast.t}' needs boolean args`);
|
||||
});
|
||||
return "boolean";
|
||||
case "call": {
|
||||
const fn = registry.get(ast.fn.toLowerCase());
|
||||
if (!fn) throw new FormulaParseError([{
|
||||
code: "UNKNOWN_FUNCTION",
|
||||
message: `Unknown function '${ast.fn}'`,
|
||||
span: { start: 0, end: 0 },
|
||||
}]);
|
||||
const argTypes = ast.args.map((a) => infer(a, propertyTypes, registry));
|
||||
if (argTypes.length < fn.arity.min || (fn.arity.max != null && argTypes.length > fn.arity.max)) {
|
||||
throw new FormulaParseError([{
|
||||
code: "ARITY_MISMATCH",
|
||||
message: `${fn.name}() expects ${fn.arity.min}-${fn.arity.max ?? "∞"} args, got ${argTypes.length}`,
|
||||
span: { start: 0, end: 0 },
|
||||
}]);
|
||||
}
|
||||
return typeof fn.returnType === "function" ? fn.returnType(argTypes) : fn.returnType;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function typeErr(message: string): FormulaParseError {
|
||||
return new FormulaParseError([{ code: "TYPE_MISMATCH", message, span: { start: 0, end: 0 } }]);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import type { FormulaAST } from "./ast";
|
||||
|
||||
export type FormulaResultType =
|
||||
| "number"
|
||||
| "string"
|
||||
| "boolean"
|
||||
| "date"
|
||||
| "null";
|
||||
|
||||
export type FormulaTypeOptions = {
|
||||
source: string;
|
||||
ast: FormulaAST;
|
||||
resultType: FormulaResultType;
|
||||
dependencies: string[];
|
||||
astVersion: 1;
|
||||
formatOptions?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
/*
|
||||
* The runtime value produced by evaluating a node. Strings and numbers are
|
||||
* their JS equivalents; dates are ISO 8601 UTC strings (matches how the date
|
||||
* property type already stores cells); booleans are booleans; missing or
|
||||
* filtered-out values are null. Errors are distinguishable from all valid
|
||||
* values because they are objects with a `__err` key.
|
||||
*/
|
||||
export type Value = number | string | boolean | null | ErrorCell;
|
||||
|
||||
export type ErrorCell = {
|
||||
__err: ErrorCode;
|
||||
msg: string;
|
||||
v: 1;
|
||||
};
|
||||
|
||||
export type ErrorCode =
|
||||
| "MISSING_PROP"
|
||||
| "TYPE_MISMATCH"
|
||||
| "DIV_BY_ZERO"
|
||||
| "DATE_INVALID"
|
||||
| "DEPTH_EXCEEDED"
|
||||
| "DEPENDENCY_ERROR";
|
||||
|
||||
/*
|
||||
* EvalContext carries everything the evaluator needs that isn't in the AST:
|
||||
* the function registry (server-only), the property map for resolving `prop`
|
||||
* nodes to their formula ASTs when nested, and the current recursion depth.
|
||||
*/
|
||||
export type EvalContext = {
|
||||
registry: ReadonlyMap<string, import("./functions/registry").FormulaFn>;
|
||||
properties: ReadonlyMap<string, PropertyLookup>;
|
||||
depth: number;
|
||||
maxDepth: number;
|
||||
memo: Map<string, Value>; // keyed by propId for the current row-eval
|
||||
};
|
||||
|
||||
export type PropertyLookup = {
|
||||
id: string;
|
||||
type: string;
|
||||
typeOptions: unknown;
|
||||
};
|
||||
|
||||
export const DEFAULT_MAX_DEPTH = 64;
|
||||
|
||||
/*
|
||||
* DoS guards. A formula source longer than MAX_FORMULA_SOURCE_LENGTH is
|
||||
* rejected before tokenizing (cheap backstop against pathological input like
|
||||
* "(".repeat(50000)). MAX_PARSE_DEPTH bounds recursive-descent nesting so a
|
||||
* deeply nested source throws a catchable FormulaParseError instead of
|
||||
* overflowing the JS stack with a RangeError. MAX_EVAL_DEPTH bounds the
|
||||
* tree-walking evaluator so an oversized AST that slipped past the parser
|
||||
* degrades to an error cell instead of crashing the recompute worker.
|
||||
*/
|
||||
export const MAX_FORMULA_SOURCE_LENGTH = 10_000;
|
||||
export const MAX_PARSE_DEPTH = 256;
|
||||
export const MAX_EVAL_DEPTH = 512;
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"extends": "../editor-ext/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"declaration": true,
|
||||
"composite": true,
|
||||
"tsBuildInfoFile": "./dist/tsconfig.tsbuildinfo"
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["dist", "node_modules"]
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
"name": "@docmost/editor-ext",
|
||||
"homepage": "https://docmost.com",
|
||||
"private": true,
|
||||
"sideEffects": false,
|
||||
"scripts": {
|
||||
"build": "tsc --build",
|
||||
"dev": "tsc --watch"
|
||||
|
||||
@@ -34,3 +34,8 @@ export * from "./lib/status";
|
||||
export * from "./lib/pdf";
|
||||
export * from "./lib/page-break";
|
||||
export * from "./lib/resizable-nodeview";
|
||||
export {
|
||||
pageNodeToDocxBuffer,
|
||||
type DocxImageResolver,
|
||||
} from "./lib/prosemirror-docx";
|
||||
export * from "./lib/base-embed";
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
import { Node, mergeAttributes } from '@tiptap/core';
|
||||
import { EditorState, NodeSelection, Plugin } from '@tiptap/pm/state';
|
||||
|
||||
export interface BaseEmbedOptions {
|
||||
HTMLAttributes: Record<string, any>;
|
||||
}
|
||||
|
||||
declare module '@tiptap/core' {
|
||||
interface Commands<ReturnType> {
|
||||
baseEmbed: {
|
||||
insertBaseEmbed: (attrs: {
|
||||
pageId: string | null;
|
||||
pendingKey?: string | null;
|
||||
}) => ReturnType;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const BaseEmbed = Node.create<BaseEmbedOptions>({
|
||||
name: 'base',
|
||||
group: 'block',
|
||||
atom: true,
|
||||
selectable: true,
|
||||
draggable: true,
|
||||
|
||||
addOptions() {
|
||||
return { HTMLAttributes: {} };
|
||||
},
|
||||
|
||||
// prosemirror-dropcursor draws a block-boundary indicator on every
|
||||
// `dragover` it sees. Pragmatic-dnd (used for column / choice reorder
|
||||
// inside the embed) fires native `dragstart`/`dragover`, which bubble
|
||||
// up to the editor and trigger dropcursor — visible as a stray blue
|
||||
// line above or below the embed during an internal drag. The cursor
|
||||
// event lands over the atom node, so dropcursor consults
|
||||
// `disableDropCursor` on this node spec; returning true suppresses
|
||||
// the indicator while still letting pragmatic-dnd handle the drag.
|
||||
extendNodeSchema(extension) {
|
||||
return extension.name === 'base'
|
||||
? { disableDropCursor: true }
|
||||
: {};
|
||||
},
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
pageId: {
|
||||
default: null,
|
||||
parseHTML: (el) => el.getAttribute('data-page-id'),
|
||||
renderHTML: (attrs) =>
|
||||
attrs.pageId ? { 'data-page-id': attrs.pageId } : {},
|
||||
},
|
||||
// Transient marker set when the slash command inserts the embed
|
||||
// before the server has assigned a pageId. The view renders a
|
||||
// skeleton in this state. Cleared once the API responds and the
|
||||
// real pageId is patched in. Not serialized — embeds saved with
|
||||
// a pendingKey would orphan if the page were closed mid-request.
|
||||
pendingKey: {
|
||||
default: null,
|
||||
parseHTML: () => null,
|
||||
renderHTML: () => ({}),
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [{ tag: 'div[data-type="base-embed"]' }];
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return [
|
||||
'div',
|
||||
mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, {
|
||||
'data-type': 'base-embed',
|
||||
}),
|
||||
];
|
||||
},
|
||||
|
||||
addCommands() {
|
||||
return {
|
||||
insertBaseEmbed:
|
||||
(attrs) =>
|
||||
({ commands }) =>
|
||||
commands.insertContent({
|
||||
type: this.name,
|
||||
attrs,
|
||||
}),
|
||||
};
|
||||
},
|
||||
|
||||
addKeyboardShortcuts() {
|
||||
// Block Backspace / Delete when the base embed itself is the
|
||||
// current selection — the "click on the embed and hit delete"
|
||||
// accidental-delete path. Returning true tells TipTap we've
|
||||
// handled the key, preventing the default removal. Range
|
||||
// selections covering the node and programmatic deletes still
|
||||
// work normally.
|
||||
const isThisNodeSelected = (): boolean => {
|
||||
const { selection } = this.editor.state;
|
||||
return (
|
||||
selection instanceof NodeSelection &&
|
||||
selection.node.type.name === this.name
|
||||
);
|
||||
};
|
||||
return {
|
||||
Backspace: () => isThisNodeSelected(),
|
||||
Delete: () => isThisNodeSelected(),
|
||||
};
|
||||
},
|
||||
|
||||
addProseMirrorPlugins() {
|
||||
// Same idea as the Backspace/Delete shortcuts above, but for the
|
||||
// other accidental-delete path: when the embed is the selection,
|
||||
// a typed character or paste would replace the whole node. These
|
||||
// hooks return true (handled, no-op) so the node stays put. The
|
||||
// user can still press an arrow key to deselect and then type.
|
||||
const nodeName = this.name;
|
||||
const isThisNodeSelected = (state: EditorState): boolean => {
|
||||
const { selection } = state;
|
||||
return (
|
||||
selection instanceof NodeSelection &&
|
||||
selection.node.type.name === nodeName
|
||||
);
|
||||
};
|
||||
return [
|
||||
new Plugin({
|
||||
props: {
|
||||
handleTextInput: (view) => isThisNodeSelected(view.state),
|
||||
handlePaste: (view) => isThisNodeSelected(view.state),
|
||||
},
|
||||
}),
|
||||
];
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,2 @@
|
||||
export { BaseEmbed } from './base-embed';
|
||||
export type { BaseEmbedOptions } from './base-embed';
|
||||
@@ -0,0 +1,167 @@
|
||||
# `prosemirror-docx`
|
||||
|
||||
[](https://www.npmjs.com/package/prosemirror-docx)
|
||||
[](https://github.com/curvenote/prosemirror-docx)
|
||||
[
|
||||
[](https://github.com/curvenote/prosemirror-docx/blob/master/LICENSE)
|
||||

|
||||
|
||||
Export a [prosemirror](https://prosemirror.net/) document to a Microsoft Word file, using [docx](https://docx.js.org/).
|
||||
|
||||

|
||||
|
||||
## Overview
|
||||
|
||||
`prosemirror-docx` has a similar structure to [prosemirror-markdown](https://github.com/prosemirror/prosemirror-markdown), with a `DocxSerializerState` object that you write to as you walk the document. It is a light wrapper around <https://docx.js.org/>, which actually does the export. Currently `prosemirror-docx` is write only (i.e. can export to, but can’t read from `*.docx`), and has most of the basic nodes covered (see below).
|
||||
|
||||
[Curvenote](https://curvenote.com) uses this to export from [@curvenote/editor](https://github.com/curvenote/editor) to word docs, but this library currently only has dependence on `docx`, `prosemirror-model` and `image-dimensions` - and similar to `prosemirror-markdown`, the serialization schema can be edited externally (see `Extended usage` below).
|
||||
|
||||
## Basic usage
|
||||
|
||||
```ts
|
||||
import { defaultDocxSerializer, writeDocx } from 'prosemirror-docx';
|
||||
import { EditorState } from 'prosemirror-state';
|
||||
import { writeFileSync } from 'fs'; // Or some other way to write a file
|
||||
|
||||
// Set up your prosemirror state/document as you normally do
|
||||
const state = EditorState.create({ schema: mySchema });
|
||||
|
||||
// If there are images, we will need to preload the buffers
|
||||
const opts = {
|
||||
getImageBuffer(src: string) {
|
||||
return anImageBuffer;
|
||||
},
|
||||
};
|
||||
|
||||
// Create a doc in memory, and then write it to disk
|
||||
const wordDocument = defaultDocxSerializer.serialize(state.doc, opts);
|
||||
|
||||
await writeDocx(wordDocument).then((buffer) => {
|
||||
writeFileSync('HelloWorld.docx', buffer);
|
||||
});
|
||||
```
|
||||
|
||||
### Advanced usage
|
||||
|
||||
If you need to access the underlying state and modify the final docx `Document` you can use the last argument of `serialize` to pass in a callback function that receives the `DocxSerializerState`.
|
||||
|
||||
This function needs to return an `IPropertiesOptions` type, ie. the config that should be passed to a `Document`. Your options will be spread with the default options, so you can override any of the defaults.
|
||||
|
||||
```ts
|
||||
const wordDocument = defaultDocxSerializer.serialize(state.doc, opts, (state) => {
|
||||
return {
|
||||
numbering: {
|
||||
config: state.numbering,
|
||||
},
|
||||
fonts: [], // embed fonts,
|
||||
styles: {
|
||||
paragraphStyles,
|
||||
default: {
|
||||
heading1: paragraphStyles[1],
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
```
|
||||
|
||||
See the [docx documentation](https://docx.js.org/#/usage/document) for more details on the options you can pass in.
|
||||
|
||||
## Extended usage
|
||||
|
||||
Instead of using the `defaultDocxSerializer` you can override or provide custom serializers.
|
||||
|
||||
```ts
|
||||
import { DocxSerializer, defaultNodes, defaultMarks } from 'prosemirror-docx';
|
||||
|
||||
const nodeSerializer = {
|
||||
...defaultNodes,
|
||||
my_paragraph(state, node) {
|
||||
state.renderInline(node);
|
||||
state.closeBlock(node);
|
||||
},
|
||||
};
|
||||
|
||||
export const myDocxSerializer = new DocxSerializer(nodeSerializer, defaultMarks);
|
||||
```
|
||||
|
||||
The `state` is the `DocxSerializerState` and has helper methods to interact with `docx`.
|
||||
|
||||
If the exported content includes image links that require fetching the image data, you can use asynchronous APIs. Here's a demo example:
|
||||
|
||||
```ts
|
||||
import { DocxSerializerAsync, defaultAsyncNodes, defaultMarks } from 'prosemirror-docx';
|
||||
import { EditorState } from 'prosemirror-state';
|
||||
import { writeFileSync } from 'fs';
|
||||
|
||||
const state = EditorState.create({ schema: mySchema });
|
||||
|
||||
export const docxSerializer = new DocxSerializerAsync(
|
||||
{
|
||||
...defaultAsyncNodes,
|
||||
async image(state, node) {
|
||||
const { src } = node.attrs;
|
||||
await state.image(src, 70, 'center', undefined, 'png');
|
||||
state.closeBlock(node);
|
||||
},
|
||||
},
|
||||
defaultMarks,
|
||||
);
|
||||
|
||||
// If there are images, we will need to preload the buffers
|
||||
const opts = {
|
||||
async getImageBuffer(src: string) {
|
||||
const arrayBuffer = await fetch(src).then((res) => res.arrayBuffer());
|
||||
return new Uint8Array(arrayBuffer);
|
||||
},
|
||||
};
|
||||
|
||||
// Create a doc in memory, and then write it to disk
|
||||
const wordDocument = docxSerializer.serializeAsync(state.doc, opts);
|
||||
|
||||
await writeDocx(wordDocument).then((buffer) => {
|
||||
writeFileSync('HelloWorld.docx', buffer);
|
||||
});
|
||||
```
|
||||
|
||||
## Supported Nodes
|
||||
|
||||
- text
|
||||
- paragraph
|
||||
- heading (levels)
|
||||
- TODO: Support numbering of headings
|
||||
- blockquote
|
||||
- code_block
|
||||
- TODO: No styles supported
|
||||
- horizontal_rule
|
||||
- hard_break
|
||||
- ordered_list
|
||||
- unordered_list
|
||||
- list_item
|
||||
- image
|
||||
- math
|
||||
- equations (numbered & unnumbered)
|
||||
- tables
|
||||
|
||||
Planned:
|
||||
|
||||
- Internal References (e.g. see Table 1)
|
||||
|
||||
## Supported Marks
|
||||
|
||||
- em
|
||||
- strong
|
||||
- link
|
||||
- Note: this is actually treated as a node in docx, so ignored as a prosemirror mark, but supported.
|
||||
- code
|
||||
- subscript
|
||||
- superscript
|
||||
- strikethrough
|
||||
- underline
|
||||
- smallcaps
|
||||
- allcaps
|
||||
|
||||
## Resources
|
||||
|
||||
- [Prosemirror Docs](https://prosemirror.net/docs/)
|
||||
- [docx](https://docx.js.org/)
|
||||
- [prosemirror-markdown](https://github.com/ProseMirror/prosemirror-markdown) - similar implementation for markdown!
|
||||
@@ -0,0 +1,24 @@
|
||||
// MIT - https://github.com/curvenote/prosemirror-docx/
|
||||
export type { SectionConfig, SerializationState } from './types';
|
||||
export type {
|
||||
MarkSerializer,
|
||||
NodeSerializer,
|
||||
NodeSerializerAsync,
|
||||
Options,
|
||||
OptionsAsync,
|
||||
} from './serializer';
|
||||
|
||||
export {
|
||||
DocxSerializerStateAsync,
|
||||
DocxSerializerAsync,
|
||||
DocxSerializerState,
|
||||
DocxSerializer,
|
||||
MAX_IMAGE_WIDTH,
|
||||
} from './serializer';
|
||||
export {
|
||||
defaultAsyncNodes,
|
||||
defaultMarks,
|
||||
pageNodeToDocxBuffer,
|
||||
type DocxImageResolver,
|
||||
} from './schema';
|
||||
export { writeDocx, createDocFromState, buildDoc } from './utils';
|
||||
@@ -0,0 +1,47 @@
|
||||
import { AlignmentType, convertInchesToTwip, ILevelsOptions, LevelFormat } from 'docx';
|
||||
import { INumbering } from './types';
|
||||
|
||||
function basicIndentStyle(indent: number): Pick<ILevelsOptions, 'style' | 'alignment'> {
|
||||
return {
|
||||
alignment: AlignmentType.START,
|
||||
style: {
|
||||
paragraph: {
|
||||
indent: { left: convertInchesToTwip(indent), hanging: convertInchesToTwip(0.18) },
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const numbered = Array(3)
|
||||
.fill([LevelFormat.DECIMAL, LevelFormat.LOWER_LETTER, LevelFormat.LOWER_ROMAN])
|
||||
.flat()
|
||||
.map((format, level) => ({
|
||||
level,
|
||||
format,
|
||||
text: `%${level + 1}.`,
|
||||
...basicIndentStyle((level + 1) / 2),
|
||||
}));
|
||||
|
||||
const bullets = Array(3)
|
||||
.fill(['●', '○', '■'])
|
||||
.flat()
|
||||
.map((text, level) => ({
|
||||
level,
|
||||
format: LevelFormat.BULLET,
|
||||
text,
|
||||
...basicIndentStyle((level + 1) / 2),
|
||||
}));
|
||||
|
||||
const styles = {
|
||||
numbered,
|
||||
bullets,
|
||||
};
|
||||
|
||||
export type NumberingStyles = keyof typeof styles;
|
||||
|
||||
export function createNumbering(reference: string, style: NumberingStyles): INumbering {
|
||||
return {
|
||||
reference,
|
||||
levels: styles[style],
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
import { HeadingLevel, ShadingType } from 'docx';
|
||||
import { Node } from 'prosemirror-model';
|
||||
import {
|
||||
DocxSerializerAsync,
|
||||
MarkSerializer,
|
||||
NodeSerializerAsync,
|
||||
OptionsAsync,
|
||||
} from './serializer';
|
||||
import { writeDocx } from './utils';
|
||||
|
||||
export type DocxImageResolver = OptionsAsync['getImageBuffer'];
|
||||
|
||||
// docx requires a 6-digit hex color (no leading #). Convert #rgb, #rrggbb,
|
||||
// and rgb()/rgba() inputs to 6-digit hex; return undefined for anything else
|
||||
// (named colors, hsl, etc.) so the caller omits the color rather than letting
|
||||
// docx throw "Invalid hex value".
|
||||
function toDocxColor(input?: string): string | undefined {
|
||||
if (!input) return undefined;
|
||||
const value = input.trim().toLowerCase();
|
||||
const hex = value.startsWith('#') ? value.slice(1) : value;
|
||||
if (/^[0-9a-f]{6}$/.test(hex)) return hex;
|
||||
if (/^[0-9a-f]{3}$/.test(hex)) {
|
||||
return hex
|
||||
.split('')
|
||||
.map((ch) => ch + ch)
|
||||
.join('');
|
||||
}
|
||||
const rgb = value.match(/^rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/);
|
||||
if (rgb) {
|
||||
const channel = (n: string) =>
|
||||
Math.max(0, Math.min(255, parseInt(n, 10)))
|
||||
.toString(16)
|
||||
.padStart(2, '0');
|
||||
return channel(rgb[1]) + channel(rgb[2]) + channel(rgb[3]);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Images and diagrams embed via the image resolver; the URL (with its file
|
||||
// extension) is passed through so docx can infer the image type.
|
||||
const renderImage: NodeSerializerAsync[string] = async (state, node) => {
|
||||
const src = node.attrs?.src || node.attrs?.attachmentId;
|
||||
if (src) {
|
||||
try {
|
||||
await state.image(src, 100);
|
||||
} catch {
|
||||
// Unrenderable/missing image: skip rather than fail the whole export.
|
||||
}
|
||||
}
|
||||
state.closeBlock(node);
|
||||
};
|
||||
|
||||
// Non-embeddable media render as a labelled line.
|
||||
const renderFileLine: NodeSerializerAsync[string] = (state, node) => {
|
||||
const label =
|
||||
node.attrs?.name || node.attrs?.src || node.attrs?.url || 'attachment';
|
||||
state.text(label);
|
||||
state.closeBlock(node);
|
||||
};
|
||||
|
||||
const renderEmbedLine: NodeSerializerAsync[string] = (state, node) => {
|
||||
const label = node.attrs?.src || node.attrs?.url || 'embed';
|
||||
state.text(label);
|
||||
state.closeBlock(node);
|
||||
};
|
||||
|
||||
export const defaultAsyncNodes: NodeSerializerAsync = {
|
||||
text(state, node) {
|
||||
state.text(node.text ?? '');
|
||||
},
|
||||
async paragraph(state, node) {
|
||||
await state.renderInline(node);
|
||||
state.closeBlock(node);
|
||||
},
|
||||
async heading(state, node) {
|
||||
await state.renderInline(node);
|
||||
const heading = [
|
||||
HeadingLevel.HEADING_1,
|
||||
HeadingLevel.HEADING_2,
|
||||
HeadingLevel.HEADING_3,
|
||||
HeadingLevel.HEADING_4,
|
||||
HeadingLevel.HEADING_5,
|
||||
HeadingLevel.HEADING_6,
|
||||
][(node.attrs.level ?? 1) - 1];
|
||||
state.closeBlock(node, { heading });
|
||||
},
|
||||
async blockquote(state, node) {
|
||||
await state.renderContent(node, { style: 'IntenseQuote' });
|
||||
},
|
||||
async codeBlock(state, node) {
|
||||
await state.renderContent(node);
|
||||
state.closeBlock(node);
|
||||
},
|
||||
horizontalRule(state, node) {
|
||||
state.closeBlock(node, { thematicBreak: true });
|
||||
state.closeBlock(node);
|
||||
},
|
||||
hardBreak(state) {
|
||||
state.addRunOptions({ break: 1 });
|
||||
},
|
||||
async bulletList(state, node) {
|
||||
await state.renderList(node, 'bullets');
|
||||
},
|
||||
async orderedList(state, node) {
|
||||
await state.renderList(node, 'numbered');
|
||||
},
|
||||
async listItem(state, node) {
|
||||
await state.renderListItem(node);
|
||||
},
|
||||
async taskList(state, node) {
|
||||
await state.renderList(node, 'bullets');
|
||||
},
|
||||
async taskItem(state, node) {
|
||||
if (state.currentNumbering) {
|
||||
state.addParagraphOptions({ numbering: state.currentNumbering });
|
||||
}
|
||||
state.text(node.attrs?.checked ? '☑ ' : '☐ ');
|
||||
await state.renderContent(node);
|
||||
},
|
||||
async table(state, node) {
|
||||
await state.table(node);
|
||||
},
|
||||
// Docmost stores LaTeX in attrs.text.
|
||||
mathInline(state, node) {
|
||||
state.math(node.attrs?.text ?? '', { inline: true });
|
||||
},
|
||||
mathBlock(state, node) {
|
||||
state.math(node.attrs?.text ?? '', { inline: false, numbered: false });
|
||||
state.closeBlock(node);
|
||||
},
|
||||
image: renderImage,
|
||||
drawio: renderImage,
|
||||
excalidraw: renderImage,
|
||||
video: renderFileLine,
|
||||
audio: renderFileLine,
|
||||
pdf: renderFileLine,
|
||||
attachment: renderFileLine,
|
||||
embed: renderEmbedLine,
|
||||
youtube: renderEmbedLine,
|
||||
async callout(state, node) {
|
||||
await state.renderContent(node, { style: 'IntenseQuote' });
|
||||
},
|
||||
async details(state, node) {
|
||||
await state.renderContent(node);
|
||||
},
|
||||
async detailsSummary(state, node) {
|
||||
await state.renderInline(node);
|
||||
state.closeBlock(node, { heading: HeadingLevel.HEADING_4 });
|
||||
},
|
||||
async detailsContent(state, node) {
|
||||
await state.renderContent(node);
|
||||
},
|
||||
async columns(state, node) {
|
||||
await state.renderContent(node);
|
||||
},
|
||||
async column(state, node) {
|
||||
await state.renderContent(node);
|
||||
},
|
||||
async transclusionSource(state, node) {
|
||||
await state.renderContent(node);
|
||||
},
|
||||
mention(state, node) {
|
||||
state.text(`@${node.attrs?.label ?? ''}`);
|
||||
},
|
||||
status(state, node) {
|
||||
state.text(`[${node.attrs?.text ?? ''}]`);
|
||||
},
|
||||
pageBreak(state, node) {
|
||||
state.closeBlock(node, { pageBreakBefore: true });
|
||||
},
|
||||
// No usable static export representation: skip without failing.
|
||||
subpages() {},
|
||||
transclusionReference() {},
|
||||
};
|
||||
|
||||
export const defaultMarks: MarkSerializer = {
|
||||
bold() {
|
||||
return { bold: true };
|
||||
},
|
||||
italic() {
|
||||
return { italics: true };
|
||||
},
|
||||
strike() {
|
||||
return { strike: true };
|
||||
},
|
||||
underline() {
|
||||
return { underline: {} };
|
||||
},
|
||||
code() {
|
||||
return {
|
||||
font: { name: 'Monospace' },
|
||||
color: '000000',
|
||||
shading: { type: ShadingType.SOLID, color: 'D2D3D2', fill: 'D2D3D2' },
|
||||
};
|
||||
},
|
||||
superscript() {
|
||||
return { superScript: true };
|
||||
},
|
||||
subscript() {
|
||||
return { subScript: true };
|
||||
},
|
||||
link() {
|
||||
// Handled specifically in the serializer; Word treats links as nodes.
|
||||
return {};
|
||||
},
|
||||
highlight(_state, _node, mark) {
|
||||
const fill = toDocxColor(mark.attrs?.color);
|
||||
return fill
|
||||
? { shading: { type: ShadingType.CLEAR, fill } }
|
||||
: { highlight: 'yellow' };
|
||||
},
|
||||
// @tiptap/extension-color stores the color on the textStyle mark.
|
||||
textStyle(_state, _node, mark) {
|
||||
const color = toDocxColor(mark.attrs?.color);
|
||||
return color ? { color } : {};
|
||||
},
|
||||
// Comments are editor-only; drop the annotation in the export.
|
||||
comment() {
|
||||
return {};
|
||||
},
|
||||
};
|
||||
|
||||
export async function pageNodeToDocxBuffer(
|
||||
doc: Node,
|
||||
getImageBuffer: DocxImageResolver,
|
||||
): Promise<Buffer> {
|
||||
const serializer = new DocxSerializerAsync(defaultAsyncNodes, defaultMarks);
|
||||
const wordDoc = await serializer.serializeAsync(
|
||||
doc,
|
||||
{ getImageBuffer },
|
||||
// docx's built-in heading styles are blue (#2E74B5 / #1F4D78). The editor
|
||||
// has no heading color, so override the default heading run colors to the
|
||||
// normal text color. Sizes/italics mirror docx's own defaults so only the
|
||||
// color changes.
|
||||
() =>
|
||||
({
|
||||
styles: {
|
||||
default: {
|
||||
document: { paragraph: { spacing: { after: 160 } } },
|
||||
heading1: {
|
||||
run: { color: '000000', size: 32 },
|
||||
paragraph: {
|
||||
keepNext: true,
|
||||
keepLines: true,
|
||||
spacing: { before: 240, after: 0 },
|
||||
},
|
||||
},
|
||||
heading2: {
|
||||
run: { color: '000000', size: 26 },
|
||||
paragraph: {
|
||||
keepNext: true,
|
||||
keepLines: true,
|
||||
spacing: { before: 40, after: 0 },
|
||||
},
|
||||
},
|
||||
heading3: {
|
||||
run: { color: '000000', size: 24 },
|
||||
paragraph: {
|
||||
keepNext: true,
|
||||
keepLines: true,
|
||||
spacing: { before: 40, after: 0 },
|
||||
},
|
||||
},
|
||||
heading4: {
|
||||
run: { color: '000000', italics: true },
|
||||
paragraph: {
|
||||
keepNext: true,
|
||||
keepLines: true,
|
||||
spacing: { before: 40, after: 0 },
|
||||
},
|
||||
},
|
||||
heading5: {
|
||||
run: { color: '000000' },
|
||||
paragraph: {
|
||||
keepNext: true,
|
||||
keepLines: true,
|
||||
spacing: { before: 40, after: 0 },
|
||||
},
|
||||
},
|
||||
heading6: {
|
||||
run: { color: '000000' },
|
||||
paragraph: {
|
||||
keepNext: true,
|
||||
keepLines: true,
|
||||
spacing: { before: 40, after: 0 },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}) as any,
|
||||
);
|
||||
return writeDocx(wordDoc);
|
||||
}
|
||||
@@ -0,0 +1,925 @@
|
||||
import { Node, Mark } from 'prosemirror-model';
|
||||
import {
|
||||
IParagraphOptions,
|
||||
IRunOptions,
|
||||
Paragraph,
|
||||
TextRun,
|
||||
ExternalHyperlink,
|
||||
ParagraphChild,
|
||||
MathRun,
|
||||
Math,
|
||||
TabStopType,
|
||||
TabStopPosition,
|
||||
SequentialIdentifier,
|
||||
Bookmark,
|
||||
ImageRun,
|
||||
AlignmentType,
|
||||
Table,
|
||||
TableRow,
|
||||
TableCell,
|
||||
ITableCellOptions,
|
||||
InternalHyperlink,
|
||||
SimpleField,
|
||||
FootnoteReferenceRun,
|
||||
IImageOptions,
|
||||
Document,
|
||||
ITableOptions,
|
||||
ITableRowOptions,
|
||||
IPropertiesOptions,
|
||||
} from 'docx';
|
||||
import { imageDimensionsFromData } from 'image-dimensions';
|
||||
import { createNumbering, NumberingStyles } from './numbering';
|
||||
import { buildDoc, createShortId } from './utils';
|
||||
import { IFootnotes, INumbering, Mutable, SectionConfig, SerializationState } from './types';
|
||||
|
||||
// This is duplicated from @curvenote/schema
|
||||
export type AlignOptions = 'left' | 'center' | 'right';
|
||||
|
||||
export type NodeSerializer = Record<
|
||||
string,
|
||||
(state: DocxSerializerState, node: Node, parent: Node, index: number) => void
|
||||
>;
|
||||
|
||||
export type NodeSerializerAsync = Record<
|
||||
string,
|
||||
(state: DocxSerializerStateAsync, node: Node, parent: Node, index: number) => void | Promise<void>
|
||||
>;
|
||||
|
||||
export type MarkSerializer = Record<
|
||||
string,
|
||||
(state: DocxSerializerState | DocxSerializerStateAsync, node: Node, mark: Mark) => IRunOptions
|
||||
>;
|
||||
|
||||
export type Options = {
|
||||
getImageBuffer: (src: string) => Uint8Array;
|
||||
sections?: SectionConfig[];
|
||||
};
|
||||
|
||||
export type OptionsAsync = {
|
||||
getImageBuffer: (src: string) => Uint8Array | Promise<Uint8Array>;
|
||||
sections?: SectionConfig[];
|
||||
};
|
||||
|
||||
export type IMathOpts = {
|
||||
inline?: boolean;
|
||||
id?: string | null;
|
||||
numbered?: boolean;
|
||||
};
|
||||
export type ImageType = 'jpg' | 'png' | 'gif' | 'bmp';
|
||||
|
||||
export const MAX_IMAGE_WIDTH = 600;
|
||||
|
||||
function createReferenceBookmark(
|
||||
id: string,
|
||||
kind: 'Equation' | 'Figure' | 'Table',
|
||||
before?: string,
|
||||
after?: string,
|
||||
) {
|
||||
const textBefore = before ? [new TextRun(before)] : [];
|
||||
const textAfter = after ? [new TextRun(after)] : [];
|
||||
return new Bookmark({
|
||||
id,
|
||||
children: [...textBefore, new SequentialIdentifier(kind), ...textAfter],
|
||||
});
|
||||
}
|
||||
|
||||
export class DocxSerializerState {
|
||||
nodes: NodeSerializer;
|
||||
|
||||
options: Options;
|
||||
|
||||
marks: MarkSerializer;
|
||||
|
||||
children: (Paragraph | Table)[];
|
||||
|
||||
sections: Array<{
|
||||
config: SectionConfig;
|
||||
children: (Paragraph | Table)[];
|
||||
}>;
|
||||
|
||||
currentSectionIndex = 0;
|
||||
|
||||
numbering: INumbering[];
|
||||
|
||||
footnotes: IFootnotes = {};
|
||||
|
||||
nextRunOpts?: IRunOptions;
|
||||
|
||||
current: ParagraphChild[] = [];
|
||||
|
||||
currentLink?: { link: string; children: IRunOptions[] };
|
||||
|
||||
// Optionally add options
|
||||
nextParentParagraphOpts?: IParagraphOptions;
|
||||
|
||||
currentNumbering?: { reference: string; level: number };
|
||||
|
||||
constructor(nodes: NodeSerializer, marks: MarkSerializer, options: Options) {
|
||||
this.nodes = nodes;
|
||||
this.marks = marks;
|
||||
this.options = options ?? ({} as Options);
|
||||
this.children = [];
|
||||
this.numbering = [];
|
||||
|
||||
// Initialize sections
|
||||
if (options.sections && options.sections.length > 0) {
|
||||
this.sections = options.sections.map((config) => ({
|
||||
config,
|
||||
children: [],
|
||||
}));
|
||||
this.children = this.sections[0].children;
|
||||
} else {
|
||||
this.sections = [];
|
||||
}
|
||||
}
|
||||
|
||||
renderContent(parent: Node, opts?: IParagraphOptions) {
|
||||
parent.forEach((node, _, i) => {
|
||||
if (opts) this.addParagraphOptions(opts);
|
||||
this.render(node, parent, i);
|
||||
});
|
||||
}
|
||||
|
||||
render(node: Node, parent: Node, index: number) {
|
||||
if (typeof parent === 'number') throw new Error('!');
|
||||
if (!this.nodes[node.type.name])
|
||||
throw new Error(`Token type \`${node.type.name}\` not supported by Word renderer`);
|
||||
this.nodes[node.type.name](this, node, parent, index);
|
||||
}
|
||||
|
||||
renderMarks(node: Node, marks: Mark[]): IRunOptions {
|
||||
return marks
|
||||
.map((mark) => {
|
||||
return this.marks[mark.type.name]?.(this, node, mark);
|
||||
})
|
||||
.reduce((a, b) => ({ ...a, ...b }), {});
|
||||
}
|
||||
|
||||
renderInline(parent: Node) {
|
||||
// Pop the stack over to this object when we encounter a link, and closeLink restores it
|
||||
let currentLink: { link: string; stack: ParagraphChild[] } | undefined;
|
||||
const closeLink = () => {
|
||||
if (!currentLink) return;
|
||||
const hyperlink = new ExternalHyperlink({
|
||||
link: currentLink.link,
|
||||
// child: this.current[0],
|
||||
children: this.current,
|
||||
});
|
||||
this.current = [...currentLink.stack, hyperlink];
|
||||
currentLink = undefined;
|
||||
};
|
||||
const openLink = (href: string) => {
|
||||
const sameLink = href === currentLink?.link;
|
||||
this.addRunOptions({ style: 'Hyperlink' });
|
||||
// TODO: https://github.com/dolanmiu/docx/issues/1119
|
||||
// Remove the if statement here and oneLink!
|
||||
const oneLink = true;
|
||||
if (!oneLink) {
|
||||
closeLink();
|
||||
} else {
|
||||
if (currentLink && sameLink) return;
|
||||
if (currentLink && !sameLink) {
|
||||
// Close previous, and open a new one
|
||||
closeLink();
|
||||
}
|
||||
}
|
||||
currentLink = {
|
||||
link: href,
|
||||
stack: this.current,
|
||||
};
|
||||
this.current = [];
|
||||
};
|
||||
const progress = (node: Node, offset: number, index: number) => {
|
||||
const links = node.marks.filter((m) => m.type.name === 'link');
|
||||
const hasLink = links.length > 0;
|
||||
if (hasLink) {
|
||||
openLink(links[0].attrs.href);
|
||||
} else if (!hasLink && currentLink) {
|
||||
closeLink();
|
||||
}
|
||||
if (node.isText) {
|
||||
this.text(node.text, this.renderMarks(node, [...node.marks]));
|
||||
} else {
|
||||
this.render(node, parent, index);
|
||||
}
|
||||
};
|
||||
parent.forEach(progress);
|
||||
// Must call close at the end of everything, just in case
|
||||
closeLink();
|
||||
}
|
||||
|
||||
renderList(node: Node, style: NumberingStyles) {
|
||||
if (!this.currentNumbering) {
|
||||
const nextId = createShortId();
|
||||
this.numbering.push(createNumbering(nextId, style));
|
||||
this.currentNumbering = { reference: nextId, level: 0 };
|
||||
} else {
|
||||
const { reference, level } = this.currentNumbering;
|
||||
this.currentNumbering = { reference, level: level + 1 };
|
||||
}
|
||||
this.renderContent(node);
|
||||
if (this.currentNumbering.level === 0) {
|
||||
delete this.currentNumbering;
|
||||
} else {
|
||||
const { reference, level } = this.currentNumbering;
|
||||
this.currentNumbering = { reference, level: level - 1 };
|
||||
}
|
||||
}
|
||||
|
||||
// This is a pass through to the paragraphs, etc. underneath they will close the block
|
||||
renderListItem(node: Node) {
|
||||
if (!this.currentNumbering) throw new Error('Trying to create a list item without a list?');
|
||||
this.addParagraphOptions({ numbering: this.currentNumbering });
|
||||
this.renderContent(node);
|
||||
}
|
||||
|
||||
addParagraphOptions(opts: IParagraphOptions) {
|
||||
this.nextParentParagraphOpts = { ...this.nextParentParagraphOpts, ...opts };
|
||||
}
|
||||
|
||||
addRunOptions(opts: IRunOptions) {
|
||||
this.nextRunOpts = { ...this.nextRunOpts, ...opts };
|
||||
}
|
||||
|
||||
text(text: string | null | undefined, opts?: IRunOptions) {
|
||||
if (!text) return;
|
||||
this.current.push(new TextRun({ text, ...this.nextRunOpts, ...opts }));
|
||||
delete this.nextRunOpts;
|
||||
}
|
||||
|
||||
math(latex: string, opts: IMathOpts = { inline: true }) {
|
||||
if (opts.inline || !opts.numbered) {
|
||||
this.current.push(new Math({ children: [new MathRun(latex)] }));
|
||||
return;
|
||||
}
|
||||
const id = opts.id ?? createShortId();
|
||||
this.current = [
|
||||
new TextRun('\t'),
|
||||
new Math({
|
||||
children: [new MathRun(latex)],
|
||||
}),
|
||||
new TextRun('\t('),
|
||||
createReferenceBookmark(id, 'Equation'),
|
||||
new TextRun(')'),
|
||||
];
|
||||
this.addParagraphOptions({
|
||||
tabStops: [
|
||||
{
|
||||
type: TabStopType.CENTER,
|
||||
position: TabStopPosition.MAX / 2,
|
||||
},
|
||||
{
|
||||
type: TabStopType.RIGHT,
|
||||
position: TabStopPosition.MAX,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
// not sure what this actually is, seems to be close for 8.5x11
|
||||
maxImageWidth = MAX_IMAGE_WIDTH;
|
||||
|
||||
image(
|
||||
src: string,
|
||||
widthPercent = 70,
|
||||
align: AlignOptions = 'center',
|
||||
imageRunOpts?: IImageOptions,
|
||||
imageType?: ImageType,
|
||||
) {
|
||||
const buffer = this.options.getImageBuffer(src);
|
||||
const dimensions = imageDimensionsFromData(buffer);
|
||||
/* If the image is not a valid image, don't add it */
|
||||
if (!dimensions) return;
|
||||
const aspect = dimensions.height / dimensions.width;
|
||||
const width = this.maxImageWidth * (widthPercent / 100);
|
||||
let it;
|
||||
try {
|
||||
it = imageType || (src.replace(/.*\./, '').toLowerCase() as any);
|
||||
} catch (e) {
|
||||
it = 'png';
|
||||
}
|
||||
this.current.push(
|
||||
new ImageRun({
|
||||
data: buffer,
|
||||
...imageRunOpts,
|
||||
type: it,
|
||||
transformation: {
|
||||
...(imageRunOpts?.transformation || {}),
|
||||
width,
|
||||
height: width * aspect,
|
||||
},
|
||||
}),
|
||||
);
|
||||
let alignment: string;
|
||||
switch (align) {
|
||||
case 'right':
|
||||
alignment = AlignmentType.RIGHT;
|
||||
break;
|
||||
case 'left':
|
||||
alignment = AlignmentType.LEFT;
|
||||
break;
|
||||
default:
|
||||
alignment = AlignmentType.CENTER;
|
||||
}
|
||||
this.addParagraphOptions({
|
||||
alignment: alignment as any,
|
||||
});
|
||||
}
|
||||
|
||||
table(
|
||||
node: Node,
|
||||
opts: {
|
||||
getCellOptions?: (cell: Node) => ITableCellOptions;
|
||||
getRowOptions?: (row: Node) => Omit<ITableRowOptions, 'children'>;
|
||||
tableOptions?: Omit<ITableOptions, 'rows'>;
|
||||
} = {},
|
||||
) {
|
||||
const { getCellOptions, getRowOptions, tableOptions } = opts;
|
||||
const actualChildren = this.children;
|
||||
const rows: TableRow[] = [];
|
||||
node.content.forEach((row) => {
|
||||
const cells: TableCell[] = [];
|
||||
// Check if all cells are headers in this row
|
||||
let tableHeader = true;
|
||||
row.content.forEach((cell) => {
|
||||
if (cell.type.name !== 'tableHeader') {
|
||||
tableHeader = false;
|
||||
}
|
||||
});
|
||||
// This scales images inside of tables
|
||||
this.maxImageWidth = MAX_IMAGE_WIDTH / row.content.childCount;
|
||||
row.content.forEach((cell) => {
|
||||
this.children = [];
|
||||
this.renderContent(cell);
|
||||
const tableCellOpts: Mutable<ITableCellOptions> = { children: this.children };
|
||||
const colspan = cell.attrs.colspan ?? 1;
|
||||
const rowspan = cell.attrs.rowspan ?? 1;
|
||||
if (colspan > 1) tableCellOpts.columnSpan = colspan;
|
||||
if (rowspan > 1) tableCellOpts.rowSpan = rowspan;
|
||||
cells.push(
|
||||
new TableCell({
|
||||
...tableCellOpts,
|
||||
...(getCellOptions?.(cell) || {}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
rows.push(new TableRow({ ...(getRowOptions?.(row) || {}), children: cells, tableHeader }));
|
||||
});
|
||||
this.maxImageWidth = MAX_IMAGE_WIDTH;
|
||||
const table = new Table({ ...tableOptions, rows });
|
||||
actualChildren.push(table);
|
||||
// If there are multiple tables, this seperates them
|
||||
actualChildren.push(new Paragraph(''));
|
||||
this.children = actualChildren;
|
||||
}
|
||||
|
||||
captionLabel(id: string, kind: 'Figure' | 'Table', { suffix } = { suffix: ': ' }) {
|
||||
this.current.push(...[createReferenceBookmark(id, kind, `${kind} `), new TextRun(suffix)]);
|
||||
}
|
||||
|
||||
$footnoteCounter = 0;
|
||||
|
||||
footnote(node: Node) {
|
||||
const { current, nextRunOpts } = this;
|
||||
// Delete everything and work with the footnote inline on the current
|
||||
this.current = [];
|
||||
delete this.nextRunOpts;
|
||||
|
||||
this.$footnoteCounter += 1;
|
||||
this.renderInline(node);
|
||||
this.footnotes[this.$footnoteCounter] = {
|
||||
children: [new Paragraph({ children: this.current })],
|
||||
};
|
||||
this.current = current;
|
||||
this.nextRunOpts = nextRunOpts;
|
||||
this.current.push(new FootnoteReferenceRun(this.$footnoteCounter));
|
||||
}
|
||||
|
||||
closeBlock(node: Node, props?: IParagraphOptions) {
|
||||
const paragraph = new Paragraph({
|
||||
children: this.current,
|
||||
...this.nextParentParagraphOpts,
|
||||
...props,
|
||||
});
|
||||
this.current = [];
|
||||
delete this.nextParentParagraphOpts;
|
||||
this.children.push(paragraph);
|
||||
}
|
||||
|
||||
/**
|
||||
* Move to the next section. If no more sections are available,
|
||||
* this will be ignored (content continues in current section).
|
||||
*/
|
||||
nextSection() {
|
||||
if (this.currentSectionIndex < this.sections.length - 1) {
|
||||
this.currentSectionIndex += 1;
|
||||
this.children = this.sections[this.currentSectionIndex].children;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the current section's configuration
|
||||
*/
|
||||
setSectionConfig(config: Partial<SectionConfig>) {
|
||||
this.sections[this.currentSectionIndex].config = {
|
||||
...this.sections[this.currentSectionIndex].config,
|
||||
...config,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a new section with the given configuration and switch to it
|
||||
*/
|
||||
addSection(config: SectionConfig = {}) {
|
||||
this.sections.push({
|
||||
config,
|
||||
children: [],
|
||||
});
|
||||
this.currentSectionIndex = this.sections.length - 1;
|
||||
this.children = this.sections[this.currentSectionIndex].children;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current section index
|
||||
*/
|
||||
getCurrentSectionIndex(): number {
|
||||
return this.currentSectionIndex;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current section configuration
|
||||
*/
|
||||
getCurrentSectionConfig(): SectionConfig {
|
||||
return this.sections[this.currentSectionIndex].config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current serialization state for document creation
|
||||
*/
|
||||
getSerializationState(): SerializationState {
|
||||
return {
|
||||
numbering: this.numbering,
|
||||
sections: this.sections,
|
||||
footnotes: this.footnotes,
|
||||
};
|
||||
}
|
||||
|
||||
createReference(id: string, before?: string, after?: string) {
|
||||
const children: ParagraphChild[] = [];
|
||||
if (before) children.push(new TextRun(before));
|
||||
children.push(new SimpleField(`REF ${id} \\h`));
|
||||
if (after) children.push(new TextRun(after));
|
||||
const ref = new InternalHyperlink({ anchor: id, children });
|
||||
this.current.push(ref);
|
||||
}
|
||||
}
|
||||
|
||||
export class DocxSerializer {
|
||||
nodes: NodeSerializer;
|
||||
|
||||
marks: MarkSerializer;
|
||||
|
||||
constructor(nodes: NodeSerializer, marks: MarkSerializer) {
|
||||
this.nodes = nodes;
|
||||
this.marks = marks;
|
||||
}
|
||||
|
||||
serialize(
|
||||
content: Node,
|
||||
options: Options,
|
||||
getDocumentOptions?: (state: SerializationState) => IPropertiesOptions,
|
||||
): Document {
|
||||
const state = new DocxSerializerState(this.nodes, this.marks, options);
|
||||
state.renderContent(content);
|
||||
return buildDoc(state, getDocumentOptions?.(state));
|
||||
}
|
||||
}
|
||||
|
||||
export class DocxSerializerStateAsync {
|
||||
nodes: NodeSerializerAsync;
|
||||
|
||||
options: OptionsAsync;
|
||||
|
||||
marks: MarkSerializer;
|
||||
|
||||
children: (Paragraph | Table)[];
|
||||
|
||||
sections: Array<{
|
||||
config: SectionConfig;
|
||||
children: (Paragraph | Table)[];
|
||||
}>;
|
||||
|
||||
currentSectionIndex = 0;
|
||||
|
||||
numbering: INumbering[];
|
||||
|
||||
footnotes: IFootnotes = {};
|
||||
|
||||
nextRunOpts?: IRunOptions;
|
||||
|
||||
current: ParagraphChild[] = [];
|
||||
|
||||
currentLink?: { link: string; children: IRunOptions[] };
|
||||
|
||||
// Optionally add options
|
||||
nextParentParagraphOpts?: IParagraphOptions;
|
||||
|
||||
currentNumbering?: { reference: string; level: number };
|
||||
|
||||
constructor(nodes: NodeSerializerAsync, marks: MarkSerializer, options: OptionsAsync) {
|
||||
this.nodes = nodes;
|
||||
this.marks = marks;
|
||||
this.options = options ?? ({} as OptionsAsync);
|
||||
this.children = [];
|
||||
this.numbering = [];
|
||||
|
||||
// Initialize sections
|
||||
if (options.sections && options.sections.length > 0) {
|
||||
this.sections = options.sections.map((config) => ({
|
||||
config,
|
||||
children: [],
|
||||
}));
|
||||
this.children = this.sections[0].children;
|
||||
} else {
|
||||
this.sections = [];
|
||||
}
|
||||
}
|
||||
|
||||
async renderContent(parent: Node, opts?: IParagraphOptions) {
|
||||
for (let i = 0; i < parent.childCount; i += 1) {
|
||||
const node = parent.child(i);
|
||||
if (opts) this.addParagraphOptions(opts);
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await this.render(node, parent, i);
|
||||
}
|
||||
}
|
||||
|
||||
async render(node: Node, parent: Node, index: number) {
|
||||
if (typeof parent === 'number') throw new Error('!');
|
||||
if (!this.nodes[node.type.name])
|
||||
throw new Error(`Token type \`${node.type.name}\` not supported by Word renderer`);
|
||||
await Promise.resolve(this.nodes[node.type.name](this, node, parent, index));
|
||||
}
|
||||
|
||||
renderMarks(node: Node, marks: Mark[]): IRunOptions {
|
||||
return marks
|
||||
.map((mark) => {
|
||||
return this.marks[mark.type.name]?.(this, node, mark);
|
||||
})
|
||||
.reduce((a, b) => ({ ...a, ...b }), {});
|
||||
}
|
||||
|
||||
async renderInline(parent: Node) {
|
||||
// Pop the stack over to this object when we encounter a link, and closeLink restores it
|
||||
let currentLink: { link: string; stack: ParagraphChild[] } | undefined;
|
||||
const closeLink = () => {
|
||||
if (!currentLink) return;
|
||||
const hyperlink = new ExternalHyperlink({
|
||||
link: currentLink.link,
|
||||
// child: this.current[0],
|
||||
children: this.current,
|
||||
});
|
||||
this.current = [...currentLink.stack, hyperlink];
|
||||
currentLink = undefined;
|
||||
};
|
||||
const openLink = (href: string) => {
|
||||
const sameLink = href === currentLink?.link;
|
||||
this.addRunOptions({ style: 'Hyperlink' });
|
||||
// TODO: https://github.com/dolanmiu/docx/issues/1119
|
||||
// Remove the if statement here and oneLink!
|
||||
const oneLink = true;
|
||||
if (!oneLink) {
|
||||
closeLink();
|
||||
} else {
|
||||
if (currentLink && sameLink) return;
|
||||
if (currentLink && !sameLink) {
|
||||
// Close previous, and open a new one
|
||||
closeLink();
|
||||
}
|
||||
}
|
||||
currentLink = {
|
||||
link: href,
|
||||
stack: this.current,
|
||||
};
|
||||
this.current = [];
|
||||
};
|
||||
const progress = async (node: Node, offset: number, index: number) => {
|
||||
const links = node.marks.filter((m) => m.type.name === 'link');
|
||||
const hasLink = links.length > 0;
|
||||
if (hasLink) {
|
||||
openLink(links[0].attrs.href);
|
||||
} else if (!hasLink && currentLink) {
|
||||
closeLink();
|
||||
}
|
||||
if (node.isText) {
|
||||
this.text(node.text, this.renderMarks(node, [...node.marks]));
|
||||
} else {
|
||||
await this.render(node, parent, index);
|
||||
}
|
||||
};
|
||||
// Process nodes sequentially to maintain order
|
||||
for (let i = 0; i < parent.childCount; i += 1) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await progress(parent.child(i), 0, i);
|
||||
}
|
||||
// Must call close at the end of everything, just in case
|
||||
closeLink();
|
||||
}
|
||||
|
||||
async renderList(node: Node, style: NumberingStyles) {
|
||||
if (!this.currentNumbering) {
|
||||
const nextId = createShortId();
|
||||
this.numbering.push(createNumbering(nextId, style));
|
||||
this.currentNumbering = { reference: nextId, level: 0 };
|
||||
} else {
|
||||
const { reference, level } = this.currentNumbering;
|
||||
this.currentNumbering = { reference, level: level + 1 };
|
||||
}
|
||||
await this.renderContent(node);
|
||||
if (this.currentNumbering.level === 0) {
|
||||
delete this.currentNumbering;
|
||||
} else {
|
||||
const { reference, level } = this.currentNumbering;
|
||||
this.currentNumbering = { reference, level: level - 1 };
|
||||
}
|
||||
}
|
||||
|
||||
// This is a pass through to the paragraphs, etc. underneath they will close the block
|
||||
async renderListItem(node: Node) {
|
||||
if (!this.currentNumbering) throw new Error('Trying to create a list item without a list?');
|
||||
this.addParagraphOptions({ numbering: this.currentNumbering });
|
||||
await this.renderContent(node);
|
||||
}
|
||||
|
||||
addParagraphOptions(opts: IParagraphOptions) {
|
||||
this.nextParentParagraphOpts = { ...this.nextParentParagraphOpts, ...opts };
|
||||
}
|
||||
|
||||
addRunOptions(opts: IRunOptions) {
|
||||
this.nextRunOpts = { ...this.nextRunOpts, ...opts };
|
||||
}
|
||||
|
||||
text(text: string | null | undefined, opts?: IRunOptions) {
|
||||
if (!text) return;
|
||||
this.current.push(new TextRun({ text, ...this.nextRunOpts, ...opts }));
|
||||
delete this.nextRunOpts;
|
||||
}
|
||||
|
||||
math(latex: string, opts: IMathOpts = { inline: true }) {
|
||||
if (opts.inline || !opts.numbered) {
|
||||
this.current.push(new Math({ children: [new MathRun(latex)] }));
|
||||
return;
|
||||
}
|
||||
const id = opts.id ?? createShortId();
|
||||
this.current = [
|
||||
new TextRun('\t'),
|
||||
new Math({
|
||||
children: [new MathRun(latex)],
|
||||
}),
|
||||
new TextRun('\t('),
|
||||
createReferenceBookmark(id, 'Equation'),
|
||||
new TextRun(')'),
|
||||
];
|
||||
this.addParagraphOptions({
|
||||
tabStops: [
|
||||
{
|
||||
type: TabStopType.CENTER,
|
||||
position: TabStopPosition.MAX / 2,
|
||||
},
|
||||
{
|
||||
type: TabStopType.RIGHT,
|
||||
position: TabStopPosition.MAX,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
// not sure what this actually is, seems to be close for 8.5x11
|
||||
maxImageWidth = MAX_IMAGE_WIDTH;
|
||||
|
||||
async image(
|
||||
src: string,
|
||||
widthPercent = 70,
|
||||
align: AlignOptions = 'center',
|
||||
imageRunOpts?: IImageOptions,
|
||||
imageType?: ImageType,
|
||||
) {
|
||||
const buffer = await Promise.resolve(this.options.getImageBuffer(src));
|
||||
const dimensions = imageDimensionsFromData(buffer);
|
||||
/* If the image is not a valid image, don't add it */
|
||||
if (!dimensions) return;
|
||||
const aspect = dimensions.height / dimensions.width;
|
||||
const width = this.maxImageWidth * (widthPercent / 100);
|
||||
let it;
|
||||
try {
|
||||
it = imageType || (src.replace(/.*\./, '').toLowerCase() as any);
|
||||
} catch (e) {
|
||||
it = 'png';
|
||||
}
|
||||
this.current.push(
|
||||
new ImageRun({
|
||||
data: buffer,
|
||||
...imageRunOpts,
|
||||
type: it,
|
||||
transformation: {
|
||||
...(imageRunOpts?.transformation || {}),
|
||||
width,
|
||||
height: width * aspect,
|
||||
},
|
||||
}),
|
||||
);
|
||||
let alignment: string;
|
||||
switch (align) {
|
||||
case 'right':
|
||||
alignment = AlignmentType.RIGHT;
|
||||
break;
|
||||
case 'left':
|
||||
alignment = AlignmentType.LEFT;
|
||||
break;
|
||||
default:
|
||||
alignment = AlignmentType.CENTER;
|
||||
}
|
||||
this.addParagraphOptions({
|
||||
alignment: alignment as any,
|
||||
});
|
||||
}
|
||||
|
||||
async table(
|
||||
node: Node,
|
||||
opts: {
|
||||
getCellOptions?: (cell: Node) => ITableCellOptions;
|
||||
getRowOptions?: (row: Node) => Omit<ITableRowOptions, 'children'>;
|
||||
tableOptions?: Omit<ITableOptions, 'rows'>;
|
||||
} = {},
|
||||
) {
|
||||
const { getCellOptions, getRowOptions, tableOptions } = opts;
|
||||
const actualChildren = this.children;
|
||||
const rows: TableRow[] = [];
|
||||
|
||||
for (let rowIndex = 0; rowIndex < node.content.childCount; rowIndex += 1) {
|
||||
const row = node.content.child(rowIndex);
|
||||
const cells: TableCell[] = [];
|
||||
// Check if all cells are headers in this row
|
||||
let tableHeader = true;
|
||||
|
||||
// Check if all cells in the row are headers
|
||||
for (let cellIndex = 0; cellIndex < row.content.childCount; cellIndex += 1) {
|
||||
const cell = row.content.child(cellIndex);
|
||||
if (cell.type.name !== 'tableHeader') {
|
||||
tableHeader = false;
|
||||
}
|
||||
}
|
||||
// This scales images inside of tables
|
||||
this.maxImageWidth = MAX_IMAGE_WIDTH / row.content.childCount;
|
||||
|
||||
// Iterate through cells and ensure order
|
||||
for (let cellIndex = 0; cellIndex < row.content.childCount; cellIndex += 1) {
|
||||
const cell = row.content.child(cellIndex);
|
||||
this.children = [];
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await this.renderContent(cell); // Ensure order
|
||||
const tableCellOpts: Mutable<ITableCellOptions> = { children: this.children };
|
||||
const colspan = cell.attrs.colspan ?? 1;
|
||||
const rowspan = cell.attrs.rowspan ?? 1;
|
||||
if (colspan > 1) tableCellOpts.columnSpan = colspan;
|
||||
if (rowspan > 1) tableCellOpts.rowSpan = rowspan;
|
||||
cells.push(
|
||||
new TableCell({
|
||||
...tableCellOpts,
|
||||
...(getCellOptions?.(cell) || {}),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
rows.push(new TableRow({ ...(getRowOptions?.(row) || {}), children: cells, tableHeader }));
|
||||
}
|
||||
|
||||
this.maxImageWidth = MAX_IMAGE_WIDTH;
|
||||
const table = new Table({ ...tableOptions, rows });
|
||||
actualChildren.push(table);
|
||||
// If there are multiple tables, this separates them
|
||||
actualChildren.push(new Paragraph(''));
|
||||
this.children = actualChildren;
|
||||
}
|
||||
|
||||
captionLabel(id: string, kind: 'Figure' | 'Table', { suffix } = { suffix: ': ' }) {
|
||||
this.current.push(...[createReferenceBookmark(id, kind, `${kind} `), new TextRun(suffix)]);
|
||||
}
|
||||
|
||||
$footnoteCounter = 0;
|
||||
|
||||
async footnote(node: Node) {
|
||||
const { current, nextRunOpts } = this;
|
||||
// Delete everything and work with the footnote inline on the current
|
||||
this.current = [];
|
||||
delete this.nextRunOpts;
|
||||
|
||||
this.$footnoteCounter += 1;
|
||||
await this.renderInline(node);
|
||||
this.footnotes[this.$footnoteCounter] = {
|
||||
children: [new Paragraph({ children: this.current })],
|
||||
};
|
||||
this.current = current;
|
||||
this.nextRunOpts = nextRunOpts;
|
||||
this.current.push(new FootnoteReferenceRun(this.$footnoteCounter));
|
||||
}
|
||||
|
||||
closeBlock(node: Node, props?: IParagraphOptions) {
|
||||
const paragraph = new Paragraph({
|
||||
children: this.current,
|
||||
...this.nextParentParagraphOpts,
|
||||
...props,
|
||||
});
|
||||
this.current = [];
|
||||
delete this.nextParentParagraphOpts;
|
||||
this.children.push(paragraph);
|
||||
}
|
||||
|
||||
/**
|
||||
* Move to the next section. If no more sections are available,
|
||||
* this will be ignored (content continues in current section).
|
||||
*/
|
||||
nextSection() {
|
||||
if (this.currentSectionIndex < this.sections.length - 1) {
|
||||
this.currentSectionIndex += 1;
|
||||
this.children = this.sections[this.currentSectionIndex].children;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the current section's configuration
|
||||
*/
|
||||
setSectionConfig(config: Partial<SectionConfig>) {
|
||||
this.sections[this.currentSectionIndex].config = {
|
||||
...this.sections[this.currentSectionIndex].config,
|
||||
...config,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a new section with the given configuration and switch to it
|
||||
*/
|
||||
addSection(config: SectionConfig = {}) {
|
||||
this.sections.push({
|
||||
config,
|
||||
children: [],
|
||||
});
|
||||
this.currentSectionIndex = this.sections.length - 1;
|
||||
this.children = this.sections[this.currentSectionIndex].children;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current section index
|
||||
*/
|
||||
getCurrentSectionIndex(): number {
|
||||
return this.currentSectionIndex;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current section configuration
|
||||
*/
|
||||
getCurrentSectionConfig(): SectionConfig {
|
||||
return this.sections[this.currentSectionIndex].config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current serialization state for document creation
|
||||
*/
|
||||
getSerializationState(): SerializationState {
|
||||
return {
|
||||
numbering: this.numbering,
|
||||
sections: this.sections,
|
||||
footnotes: this.footnotes,
|
||||
};
|
||||
}
|
||||
|
||||
createReference(id: string, before?: string, after?: string) {
|
||||
const children: ParagraphChild[] = [];
|
||||
if (before) children.push(new TextRun(before));
|
||||
children.push(new SimpleField(`REF ${id} \\h`));
|
||||
if (after) children.push(new TextRun(after));
|
||||
const ref = new InternalHyperlink({ anchor: id, children });
|
||||
this.current.push(ref);
|
||||
}
|
||||
}
|
||||
|
||||
export class DocxSerializerAsync {
|
||||
nodes: NodeSerializerAsync;
|
||||
|
||||
marks: MarkSerializer;
|
||||
|
||||
constructor(nodes: NodeSerializerAsync, marks: MarkSerializer) {
|
||||
this.nodes = nodes;
|
||||
this.marks = marks;
|
||||
}
|
||||
|
||||
async serializeAsync(
|
||||
content: Node,
|
||||
options: OptionsAsync,
|
||||
getDocumentOptions?: (state: SerializationState) => IPropertiesOptions,
|
||||
) {
|
||||
const state = new DocxSerializerStateAsync(this.nodes, this.marks, options);
|
||||
await state.renderContent(content);
|
||||
return buildDoc(state, getDocumentOptions?.(state));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { INumberingOptions, Paragraph, ISectionOptions } from 'docx';
|
||||
|
||||
export type Mutable<T> = {
|
||||
-readonly [k in keyof T]: T[k];
|
||||
};
|
||||
|
||||
export type IFootnotes = Mutable<
|
||||
Readonly<
|
||||
Record<
|
||||
string,
|
||||
{
|
||||
readonly children: readonly Paragraph[];
|
||||
}
|
||||
>
|
||||
>
|
||||
>;
|
||||
|
||||
export type INumbering = INumberingOptions['config'][0];
|
||||
|
||||
export interface SectionConfig {
|
||||
properties?: ISectionOptions['properties'];
|
||||
headers?: ISectionOptions['headers'];
|
||||
footers?: ISectionOptions['footers'];
|
||||
}
|
||||
|
||||
export interface SerializationState {
|
||||
numbering: INumberingOptions['config'];
|
||||
sections?: Array<{
|
||||
config: SectionConfig;
|
||||
children: ISectionOptions['children'];
|
||||
}>;
|
||||
children?: ISectionOptions['children'];
|
||||
footnotes?: IFootnotes;
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import {
|
||||
Document,
|
||||
INumberingOptions,
|
||||
IPropertiesOptions,
|
||||
ISectionOptions,
|
||||
Packer,
|
||||
SectionType,
|
||||
} from 'docx';
|
||||
import { Node as ProsemirrorNode } from 'prosemirror-model';
|
||||
import { IFootnotes, SerializationState } from './types';
|
||||
|
||||
export function createShortId() {
|
||||
return Math.random().toString(36).slice(2, 11);
|
||||
}
|
||||
|
||||
export function buildDoc(state: SerializationState, opts?: IPropertiesOptions): Document {
|
||||
let sections = state?.sections?.length
|
||||
? state.sections.map((section) => ({
|
||||
properties: section.config.properties || {
|
||||
type: SectionType.CONTINUOUS,
|
||||
},
|
||||
headers: section.config.headers,
|
||||
footers: section.config.footers,
|
||||
children: section.children,
|
||||
}))
|
||||
: undefined;
|
||||
if (!sections) {
|
||||
sections = [
|
||||
{
|
||||
headers: undefined,
|
||||
footers: undefined,
|
||||
properties: {
|
||||
type: SectionType.CONTINUOUS,
|
||||
},
|
||||
children: state?.children || [],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
const doc = new Document({
|
||||
footnotes: state.footnotes,
|
||||
numbering: {
|
||||
config: state.numbering,
|
||||
},
|
||||
sections,
|
||||
...(opts || {}),
|
||||
});
|
||||
return doc;
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated - use `buildDoc` instead
|
||||
* Creates a docx document from the given state.
|
||||
* */
|
||||
export function createDocFromState(state: {
|
||||
numbering: INumberingOptions['config'];
|
||||
children: ISectionOptions['children'];
|
||||
footnotes?: IFootnotes;
|
||||
}) {
|
||||
return buildDoc({
|
||||
numbering: state.numbering,
|
||||
sections: [
|
||||
{
|
||||
config: {},
|
||||
children: state.children,
|
||||
},
|
||||
],
|
||||
footnotes: state.footnotes,
|
||||
});
|
||||
}
|
||||
|
||||
export async function writeDocx(
|
||||
doc: Document,
|
||||
/**
|
||||
* @deprecated use `.then()` or `await` instead
|
||||
*/
|
||||
write?: ((buffer: Buffer) => void) | ((buffer: Buffer) => Promise<void>),
|
||||
) {
|
||||
const buffer = await Packer.toBuffer(doc);
|
||||
await write?.(buffer);
|
||||
return buffer;
|
||||
}
|
||||
|
||||
export function getLatexFromNode(node: ProsemirrorNode): string {
|
||||
let math = '';
|
||||
node.forEach((child) => {
|
||||
if (child.isText) math += child.text;
|
||||
// TODO: improve this as we may have other things in the future
|
||||
});
|
||||
return math;
|
||||
}
|
||||
@@ -422,6 +422,8 @@ export const SearchAndReplace = Extension.create<
|
||||
state: {
|
||||
init: () => DecorationSet.empty,
|
||||
apply({ doc, docChanged }, oldState) {
|
||||
const storage = editor.storage.searchAndReplace;
|
||||
if (!storage) return oldState;
|
||||
const {
|
||||
searchTerm,
|
||||
lastSearchTerm,
|
||||
@@ -429,7 +431,7 @@ export const SearchAndReplace = Extension.create<
|
||||
lastCaseSensitive,
|
||||
resultIndex,
|
||||
lastResultIndex,
|
||||
} = editor.storage.searchAndReplace;
|
||||
} = storage;
|
||||
|
||||
if (
|
||||
!docChanged &&
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
export { TableHeaderPin } from './extension';
|
||||
export { pinOffsetWatcher, EDITOR_PIN_OFFSET_VAR, computePinTop } from './offset';
|
||||
|
||||
Reference in New Issue
Block a user