import * as errors from "@superbuilders/errors"
import * as logger from "@superbuilders/slog"
import { z } from "zod"
import type { WidgetGenerator } from "@/widgets/types"
export const ErrInvalidTableData = errors.new("invalid table data")
// Cell content can be simple text or a structured object with text and/or math
const RichTextWithMathSchema = z
.object({
text: z.string().describe("Text portion of cell"),
mathml: z.string().describe("MathML portion of cell to append after text")
})
.strict()
.describe(
"Structured cell with text and MathML (e.g., {text: 'Temperature: ', mathml: '25'}). Both text and mathml are required if using this form."
)
const CellContentSchema = z.union([
z.string().describe("Plain text cell content"),
RichTextWithMathSchema
])
const RowSchema = z
.array(CellContentSchema.nullable())
.describe(
"Array of cell values for one row. Use null for empty cells. Can mix text and structured content."
)
export const DataTablePropsSchema = z
.object({
type: z
.literal("dataTable")
.describe("Identifies this as a data table widget."),
caption: z
.string()
.nullable()
.describe(
"Optional caption above the table (e.g., 'Temperature Measurements', 'Properties of Matter'). Use null for no caption."
),
headers: z
.array(CellContentSchema)
.min(1)
.describe(
"Column headers for the table (e.g., ['Month', 'Temperature', 'Rainfall']). Must have at least one header."
),
rows: z
.array(RowSchema)
.min(1)
.describe(
"Data rows. Each row is an array of cell values matching the number of headers. Must have at least one row."
),
rowHeaders: z
.boolean()
.describe(
"If true, the first column will be styled as row headers (bold). Use for tables where the first column labels each row. Set to false for regular tables."
)
})
.strict()
.describe(
"Creates a data table for displaying tabular information. Use this for ANY data table with rows and columns (temperature charts, comparison tables, measurement data, etc.). This is the standard table widget - use it for 99% of table needs."
)
export type DataTableProps = z.infer
/**
* Generates an HTML data table.
* This is the standard table widget for displaying tabular data.
*/
export const generateDataTable: WidgetGenerator<
typeof DataTablePropsSchema
> = async (data) => {
const { caption, headers, rows, rowHeaders } = data
if (headers.length === 0) {
logger.error("table has no headers", { data })
throw errors.wrap(ErrInvalidTableData, "headers array cannot be empty")
}
if (rows.length === 0) {
logger.error("table has no rows", { data })
throw errors.wrap(ErrInvalidTableData, "rows array cannot be empty")
}
// Validate all rows have correct number of cells
for (let i = 0; i < rows.length; i++) {
const row = rows[i]
if (!row) {
logger.error("table row is null/undefined", { rowIndex: i })
throw errors.wrap(ErrInvalidTableData, `row ${i} is null/undefined`)
}
if (row.length !== headers.length) {
logger.error("table row has wrong number of cells", {
rowIndex: i,
expected: headers.length,
actual: row.length
})
throw errors.wrap(
ErrInvalidTableData,
`row ${i} has ${row.length} cells but expected ${headers.length}`
)
}
}
logger.debug("generating data table", {
caption,
columnCount: headers.length,
rowCount: rows.length,
hasRowHeaders: rowHeaders
})
const parts: string[] = []
// Optional caption
if (caption) {
parts.push(
`${escapeHtml(caption)}
`
)
}
// Table start
parts.push(
''
)
// Header row
parts.push("")
for (const header of headers) {
const headerContent = renderCellContent(header)
parts.push(
`| ${headerContent} | `
)
}
parts.push("
")
// Body rows
parts.push("")
for (const row of rows) {
parts.push("")
for (let colIndex = 0; colIndex < row.length; colIndex++) {
const cell = row[colIndex]
const cellContent = cell ? renderCellContent(cell) : ""
const isRowHeader = rowHeaders && colIndex === 0
if (isRowHeader) {
// First column as row header
parts.push(
`| ${cellContent} | `
)
} else {
// Regular cell
parts.push(
`${cellContent} | `
)
}
}
parts.push("
")
}
parts.push("")
parts.push("
")
const html = parts.join("")
logger.debug("generated data table html", {
htmlLength: html.length,
rows: rows.length,
columns: headers.length
})
return html
}
/**
* Renders cell content which can be a string or structured object with text/mathml
*/
function renderCellContent(
content: string | { text: string; mathml: string }
): string {
if (typeof content === "string") {
return escapeHtml(content)
}
const parts: string[] = []
parts.push(escapeHtml(content.text))
parts.push(
``
)
return parts.join("")
}
/**
* Escapes HTML special characters to prevent XSS and rendering issues.
*/
function escapeHtml(text: string): string {
const map: Record = {
"&": "&",
"<": "<",
">": ">",
'"': """,
"'": "'"
}
return text.replace(/[&<>"']/g, (char) => map[char] ?? char)
}