Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions packages/malloy-render/CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,15 @@ Built on **Solid.js** (reactive UI) and **Vega** (declarative charts).

## Distribution & Public Surface

`package.json` `exports` declares a single `.` entry with conditions: `import` → `dist/module/index.mjs` (ESM, added in #2945 so ESM consumers like malloy-explorer resolve the ES build), `require`/`default` → the **UMD bundle** (`dist/module/index.umd.js`). There are no subpath imports and no headless entry. All integration goes through `MalloyRenderer` (or the legacy `HTMLView`, still exported for the VS Code notebook schema view).
`package.json` `exports` declares two entries:

- **`.`** with conditions: `import` → `dist/module/index.mjs` (ESM, added in #2945 so ESM consumers like malloy-explorer resolve the ES build), `require`/`default` → the **UMD bundle** (`dist/module/index.umd.js`). All rendering goes through `MalloyRenderer` (or the legacy `HTMLView`, still exported for the VS Code notebook schema view).
- **`./format`** — the one headless entry: `src/format/`, built separately by `vite.config.format.mts` to `dist/format/index.{mjs,cjs}` with its dependencies external (types from the main build's `dist/module/format/`). It holds the single-value formatters — `resolveCellFormat(tag, {isTime})` turns a field's `# currency` / `# percent` / `# duration` / `# number` tag into a plain `CellFormatConfig`, and `formatNumber` / `formatDateTime` apply one to a value. The renderer's own cells call these same functions (`component/render-numeric-field.ts` and `resolveCellFormatTags` are thin `Field` wrappers), so a value formatted through this entry reads exactly as it does in a rendered table. Keep `src/format/` free of `data_tree`, Solid, Vega and DOM imports: that is what makes it loadable in Node and in a sandboxed frame.

Two non-obvious consequences:

- **`@malloydata/malloy-tag` is a runtime dependency for type resolution only.** The UMD inlines malloy-tag (vite `external: []`); nothing escapes to `require()` at runtime. But the published `.d.ts` files (`api/plugin-types.d.ts`, `data_tree/fields/base.d.ts`, `util.d.ts`, etc.) re-export `Tag`, so consumer TypeScript needs the package installed.
- **The bundle cannot be loaded in Node without a DOM stub** (either condition). Solid.js calls `delegateEvents()` at module-eval time and reads `window.document`. The headless validator (`@malloydata/render-validator`) installs and removes global `window`/`document`/`navigator` stubs around its `require()` of this package; any other Node consumer must do the same.
- **The main bundle cannot be loaded in Node without a DOM stub** (either condition; `./format` can). Solid.js calls `delegateEvents()` at module-eval time and reads `window.document`. The headless validator (`@malloydata/render-validator`) installs and removes global `window`/`document`/`navigator` stubs around its `require()` of this package; any other Node consumer must do the same.

## Vega is pinned at v5 — a deliberate hold

Expand Down
7 changes: 5 additions & 2 deletions packages/malloy-render/femto-config.motly
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,17 @@ build: {
"src/**/*.tsx",
"src/**/*.css",
"vite.config.mts",
"vite.config.format.mts",
"tsconfig.json",
"tsconfig.type-check.json"
]
outputs = ["dist/module/index.mjs"]
outputs = ["dist/module/index.mjs", "dist/format/index.mjs"]
commands = [
# vite bundles the renderer but its dts pass only PRINTS type errors (exit 0).
# Run a real tsc first so a type error fails the build. See tsconfig.type-check.json.
"npx tsc -p tsconfig.type-check.json",
"npx vite build --outDir 'dist/module' --config vite.config.mts"
"npx vite build --outDir 'dist/module' --config vite.config.mts",
# The headless single-value formatters: @malloydata/render/format.
"npx vite build --config vite.config.format.mts"
]
}
13 changes: 13 additions & 0 deletions packages/malloy-render/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,19 @@
"import": "./dist/module/index.mjs",
"require": "./dist/module/index.umd.js",
"default": "./dist/module/index.umd.js"
},
"./format": {
"types": "./dist/module/format/index.d.ts",
"import": "./dist/format/index.mjs",
"require": "./dist/format/index.cjs",
"default": "./dist/format/index.cjs"
}
},
"typesVersions": {
"*": {
"format": [
"./dist/module/format/index.d.ts"
]
}
},
"scripts": {
Expand Down
211 changes: 14 additions & 197 deletions packages/malloy-render/src/component/render-numeric-field.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,21 +3,17 @@
* SPDX-License-Identifier: MIT
*/

import {format} from 'ssf';
import {
getText,
NULL_SYMBOL,
renderTimeString,
formatBigNumber,
formatScaledNumber,
type RenderTimeStringOptions,
} from '../util';
formatDateTime,
formatNumber,
type CellFormatConfig,
} from '../format/cell-format';
import type {RenderTimeStringOptions} from '../format/time-format';
import type {Field, NumberCell} from '../data_tree';
import type {
CellFormatConfig,
CurrencyConfig,
NumberConfig,
} from './tag-configs';

// The formatting itself lives in ../format (the headless
// `@malloydata/render/format` entry). These wrappers add only what needs a
// Field: its resolved config and its query's timezone.

/**
* Get the CellFormatConfig for a field, with optional override.
Expand Down Expand Up @@ -48,150 +44,7 @@ export function renderNumericField(
value: number | null | undefined,
configOverride?: CellFormatConfig
): string {
if (value === null || value === undefined) {
return NULL_SYMBOL;
}
const config = getConfig(f, configOverride);

switch (config.mode) {
case 'currency':
return renderCurrencyValue(config.currency, value);
case 'percent':
return format('#,##0.00%', value);
case 'duration': {
return (
getText(f, value, {
durationUnit: config.duration.unit,
terse: config.duration.terse,
}) ?? value.toLocaleString()
);
}
case 'number':
return renderNumberValue(config.number, value);
default:
return value.toLocaleString();
}
}

/**
* Renders a currency value using pre-resolved config.
*/
function renderCurrencyValue(config: CurrencyConfig, value: number): string {
if (config.scale) {
const scaledStr = formatScaledNumber(value, {
scale: config.scale,
decimals: config.decimals ?? 2,
suffix: config.suffixFormat,
});
return `${config.symbol}${scaledStr}`;
} else {
const effectiveDecimals = config.decimals ?? 2;
const formatStr =
effectiveDecimals > 0
? `${config.symbol}#,##0.${'0'.repeat(effectiveDecimals)}`
: `${config.symbol}#,##0`;
return format(formatStr, value);
}
}

/**
* Renders a number value using pre-resolved config.
*/
function renderNumberValue(config: NumberConfig, value: number): string {
if (config.isId) {
return String(value);
}

if (config.scale) {
return formatScaledNumber(value, {
scale: config.scale,
decimals: config.decimals ?? 2,
suffix: config.suffixFormat ?? 'lower',
});
}

if (config.decimals !== undefined && !config.scale) {
return Number(value.toFixed(config.decimals)).toLocaleString('en-US', {
minimumFractionDigits: 0,
maximumFractionDigits: config.decimals,
});
}

if (config.isBig) {
return formatBigNumber(value);
}

if (config.formatString) {
return format(config.formatString, value);
}

return format('#', value);
}

/**
* Format a string number with locale-style comma separators.
* Preserves full precision (no conversion to JS number).
*/
function formatStringWithCommas(value: string): string {
const isNegative = value.startsWith('-');
const absValue = isNegative ? value.slice(1) : value;
const formatted = absValue.replace(/\B(?=(\d{3})+(?!\d))/g, ',');
return isNegative ? `-${formatted}` : formatted;
}

/**
* Format a bigint string as currency with pre-resolved config.
*/
function formatBigIntCurrency(config: CurrencyConfig, value: string): string {
if (config.scale) {
const numericValue = Number(value);
const scaledStr = formatScaledNumber(numericValue, {
scale: config.scale,
decimals: config.decimals ?? 2,
suffix: config.suffixFormat,
});
return `${config.symbol}${scaledStr}`;
}

const effectiveDecimals = config.decimals ?? 2;
if (effectiveDecimals === 0) {
return `${config.symbol}${formatStringWithCommas(value)}`;
}
return `${config.symbol}${formatStringWithCommas(value)}.${'0'.repeat(effectiveDecimals)}`;
}

/**
* Format a bigint string as number with pre-resolved config.
*/
function formatBigIntNumber(config: NumberConfig, value: string): string {
if (config.isId) {
return value;
}

if (config.scale) {
return formatScaledNumber(Number(value), {
scale: config.scale,
decimals: config.decimals ?? 2,
suffix: config.suffixFormat ?? 'lower',
});
}

if (config.decimals !== undefined && !config.scale) {
if (config.decimals === 0) {
return formatStringWithCommas(value);
}
return `${formatStringWithCommas(value)}.${'0'.repeat(config.decimals)}`;
}

if (config.isBig) {
return formatBigNumber(Number(value));
}

if (config.formatString) {
return format(config.formatString, Number(value));
}

return formatStringWithCommas(value);
return formatNumber(value, getConfig(f, configOverride));
}

/**
Expand All @@ -203,23 +56,7 @@ export function renderBigNumberField(
value: string | null | undefined,
configOverride?: CellFormatConfig
): string {
if (value === null || value === undefined) {
return NULL_SYMBOL;
}
const config = getConfig(f, configOverride);

switch (config.mode) {
case 'currency':
return formatBigIntCurrency(config.currency, value);
case 'percent':
case 'duration':
// These require numeric operations - lossy for bigints (rare use case)
return renderNumericField(f, Number(value), config);
case 'number':
return formatBigIntNumber(config.number, value);
default:
return formatStringWithCommas(value);
}
return formatNumber(value, getConfig(f, configOverride));
}

/**
Expand All @@ -246,34 +83,14 @@ export function renderDateTimeField(
options: RenderTimeStringOptions = {},
configOverride?: CellFormatConfig
): string {
if (value === null || value === undefined) {
return NULL_SYMBOL;
}

const config = getConfig(f, configOverride);

// Check if the field has a date format from # number tag
if (config.mode === 'dateFormat') {
try {
return format(config.formatString, value);
} catch (error) {
// If the format fails, fall back to default formatting
// eslint-disable-next-line no-console
console.warn(
`Invalid date format "${config.formatString}" for field ${f.name}, falling back to default formatting`
);
}
}

// Get the effective query timezone for timestamp fields (not date fields)
const effectiveTimezone = !options.isDate
? (options.timezone ?? f.getEffectiveQueryTimezone())
: undefined;

const optionsWithTimezone = {
return formatDateTime(value, getConfig(f, configOverride), {
...options,
timezone: effectiveTimezone,
};

return renderTimeString(value, optionsWithTimezone);
fieldName: f.name,
});
}
Loading
Loading