/**
* HL7v2 Message Highlighter with Schema Metadata
*
* Parses HL7v2 messages and generates HTML with tooltips showing
* field names, data types, and other metadata from the HL7v2 schema.
*/
// Import schema data
const segments = require("../../hl7v2/schema/segments");
const fields = require("../../hl7v2/schema/fields");
const dataTypes = require("../../hl7v2/schema/dataTypes");
interface FieldMeta {
fieldId: string;
longName: string;
dataType: string;
required: boolean;
}
interface ComponentMeta {
componentId: string;
longName: string;
dataType: string;
}
/**
* Get field metadata for a segment field (e.g., "MSH.9")
*/
function getFieldMeta(segmentName: string, fieldIndex: number): FieldMeta | null {
const fieldId = `${segmentName}.${fieldIndex}`;
const fieldDef = fields[fieldId];
const segmentDef = segments[segmentName];
if (!fieldDef) return null;
// Find if field is required from segment definition
let required = false;
if (segmentDef?.fields) {
const fieldSpec = segmentDef.fields.find(
(f: { field: string; minOccurs: string }) => f.field === fieldId
);
required = fieldSpec?.minOccurs === "1";
}
return {
fieldId,
longName: fieldDef.longName || fieldId,
dataType: fieldDef.dataType || "",
required,
};
}
/**
* Get component metadata for a datatype component (e.g., "XPN.1")
*/
function getComponentMeta(dataTypeName: string, componentIndex: number): ComponentMeta | null {
const componentId = `${dataTypeName}.${componentIndex}`;
const componentDef = dataTypes[componentId];
if (!componentDef) return null;
return {
componentId,
longName: componentDef.longName || componentId,
dataType: componentDef.dataType || "",
};
}
/**
* Get subcomponent metadata (e.g., "FN.1" for family name surname)
*/
function getSubcomponentMeta(dataTypeName: string, subIndex: number): ComponentMeta | null {
const subId = `${dataTypeName}.${subIndex}`;
const subDef = dataTypes[subId];
if (!subDef) return null;
return {
componentId: subId,
longName: subDef.longName || subId,
dataType: subDef.dataType || "",
};
}
/**
* Escape HTML special characters
*/
function escapeHtml(text: string): string {
return text
.replace(/&/g, "&")
.replace(//g, ">")
.replace(/"/g, """)
.replace(/'/g, "'");
}
/**
* Generate tooltip HTML for a field/component
*/
function tooltip(meta: { longName: string; dataType?: string; fieldId?: string; componentId?: string }, value: string): string {
const id = meta.fieldId || meta.componentId || "";
const dt = meta.dataType ? ` (${meta.dataType})` : "";
const title = `${id}: ${meta.longName}${dt}`;
const escapedValue = escapeHtml(value);
if (!escapedValue) return "";
return `${escapedValue}`;
}
/**
* Parse and highlight subcomponents (& separated)
*/
function highlightSubcomponents(
value: string,
parentDataType: string
): string {
// Look up the parent data type to see if it has subcomponents
const parentDef = dataTypes[parentDataType];
if (!parentDef?.components) {
return escapeHtml(value);
}
const subValues = value.split("&");
const parts: string[] = [];
for (let i = 0; i < subValues.length; i++) {
const subValue = subValues[i];
if (!subValue) {
parts.push("");
continue;
}
const subMeta = getSubcomponentMeta(parentDataType, i + 1);
if (subMeta) {
parts.push(tooltip(subMeta, subValue));
} else {
parts.push(escapeHtml(subValue));
}
}
return parts.join('&');
}
/**
* Parse and highlight components (^ separated)
*/
function highlightComponents(
value: string,
fieldDataType: string,
segmentName: string,
fieldIndex: number
): string {
// Look up the field data type to see if it has components
const typeDef = dataTypes[fieldDataType];
if (!typeDef?.components) {
// Primitive type, just return escaped value
return escapeHtml(value);
}
const compValues = value.split("^");
const parts: string[] = [];
for (let i = 0; i < compValues.length; i++) {
const compValue = compValues[i];
if (!compValue) {
parts.push("");
continue;
}
const compMeta = getComponentMeta(fieldDataType, i + 1);
if (compMeta) {
// Check if this component has subcomponents
const compTypeDef = dataTypes[compMeta.dataType];
if (compTypeDef?.components && compValue.includes("&")) {
parts.push(highlightSubcomponents(compValue, compMeta.dataType));
} else {
parts.push(tooltip(compMeta, compValue));
}
} else {
parts.push(escapeHtml(compValue));
}
}
return parts.join('^');
}
/**
* Parse and highlight field repetitions (~ separated)
*/
function highlightRepetitions(
value: string,
fieldDataType: string,
segmentName: string,
fieldIndex: number
): string {
const reps = value.split("~");
const parts: string[] = [];
for (const rep of reps) {
if (!rep) {
parts.push("");
continue;
}
if (rep.includes("^")) {
parts.push(highlightComponents(rep, fieldDataType, segmentName, fieldIndex));
} else {
// Single value, check for subcomponents
const typeDef = dataTypes[fieldDataType];
if (typeDef?.components) {
const compMeta = getComponentMeta(fieldDataType, 1);
if (compMeta) {
parts.push(tooltip(compMeta, rep));
} else {
parts.push(escapeHtml(rep));
}
} else {
parts.push(escapeHtml(rep));
}
}
}
return parts.join('~');
}
/**
* Highlight a single HL7v2 segment line
*/
function highlightSegment(line: string): string {
if (!line || line.length < 3) {
return escapeHtml(line);
}
const segmentName = line.substring(0, 3);
// Special handling for MSH segment (field separator is position 3)
if (segmentName === "MSH") {
return highlightMSH(line);
}
// Regular segment - split by pipe
const pipeChar = "|";
const fieldValues = line.split(pipeChar);
const parts: string[] = [];
// First part is segment name
parts.push(`${escapeHtml(fieldValues[0])}`);
// Process each field
for (let i = 1; i < fieldValues.length; i++) {
const fieldValue = fieldValues[i];
const fieldIndex = i; // For non-MSH segments, field index matches array position
if (!fieldValue) {
parts.push("");
continue;
}
const fieldMeta = getFieldMeta(segmentName, fieldIndex);
if (fieldMeta) {
// Wrap the entire field with tooltip showing field name
const fieldDataType = fieldMeta.dataType;
let inner: string;
if (fieldValue.includes("~")) {
inner = highlightRepetitions(fieldValue, fieldDataType, segmentName, fieldIndex);
} else if (fieldValue.includes("^")) {
inner = highlightComponents(fieldValue, fieldDataType, segmentName, fieldIndex);
} else {
// Single value
inner = escapeHtml(fieldValue);
}
// Wrap with field-level tooltip
const title = `${fieldMeta.fieldId}: ${fieldMeta.longName} (${fieldMeta.dataType})${fieldMeta.required ? " [R]" : ""}`;
parts.push(`${inner}`);
} else {
parts.push(escapeHtml(fieldValue));
}
}
return parts.join('|');
}
/**
* Special handling for MSH segment where field numbering is offset
*/
function highlightMSH(line: string): string {
// MSH.1 is the field separator character itself (position 3)
// MSH.2 is the encoding characters (positions 4-7 typically)
// Fields after that are pipe-separated starting at position 8+
const segmentName = "MSH";
const parts: string[] = [];
// MSH segment name
parts.push(`MSH`);
// MSH.1 - Field Separator
const fieldSep = line[3];
const msh1Meta = getFieldMeta("MSH", 1);
if (msh1Meta) {
const title = `${msh1Meta.fieldId}: ${msh1Meta.longName}`;
parts.push(`${escapeHtml(fieldSep)}`);
} else {
parts.push(`${escapeHtml(fieldSep)}`);
}
// Rest of the line after MSH|
const rest = line.substring(4);
const fieldValues = rest.split(fieldSep);
// MSH.2 - Encoding Characters (first field after separator)
if (fieldValues.length > 0) {
const msh2Meta = getFieldMeta("MSH", 2);
if (msh2Meta) {
const title = `${msh2Meta.fieldId}: ${msh2Meta.longName}`;
parts.push(`${escapeHtml(fieldValues[0])}`);
} else {
parts.push(`${escapeHtml(fieldValues[0])}`);
}
}
// Process remaining fields (MSH.3 onwards)
for (let i = 1; i < fieldValues.length; i++) {
parts.push('|');
const fieldValue = fieldValues[i];
const fieldIndex = i + 2; // MSH fields are offset by 2 (MSH.1 is separator, MSH.2 is encoding chars)
if (!fieldValue) {
continue;
}
const fieldMeta = getFieldMeta(segmentName, fieldIndex);
if (fieldMeta) {
const fieldDataType = fieldMeta.dataType;
let inner: string;
if (fieldValue.includes("~")) {
inner = highlightRepetitions(fieldValue, fieldDataType, segmentName, fieldIndex);
} else if (fieldValue.includes("^")) {
inner = highlightComponents(fieldValue, fieldDataType, segmentName, fieldIndex);
} else {
inner = escapeHtml(fieldValue);
}
const title = `${fieldMeta.fieldId}: ${fieldMeta.longName} (${fieldMeta.dataType})${fieldMeta.required ? " [R]" : ""}`;
parts.push(`${inner}`);
} else {
parts.push(escapeHtml(fieldValue));
}
}
return parts.join("");
}
/**
* Highlight an entire HL7v2 message
* @param hl7Message - The raw HL7v2 message (segments separated by \r or \n)
* @returns HTML string with syntax highlighting and tooltips
*/
export function highlightHL7Message(hl7Message: string | undefined): string {
if (!hl7Message) {
return 'No HL7v2 message';
}
// Split by CR (HL7v2 standard) or LF (for display purposes)
const lines = hl7Message.split(/\r|\n/).filter((line) => line.trim());
const highlightedLines = lines.map((line) => highlightSegment(line));
return highlightedLines.join("\n");
}
/**
* Get CSS styles for the highlighter
*/
export function getHighlightStyles(): string {
return `
.hl7-segment {
color: #1e40af;
font-weight: 600;
}
.hl7-delim {
font-weight: bold;
}
.hl7-pipe {
color: #2563eb;
}
.hl7-comp {
color: #7c3aed;
}
.hl7-rep {
color: #059669;
}
.hl7-sub {
color: #dc2626;
}
.hl7-encoding {
color: #ea580c;
font-weight: 500;
}
.hl7-field {
cursor: help;
border-bottom: 1px dotted #9ca3af;
}
.hl7-field:hover {
background-color: #fef3c7;
}
.hl7-field-wrap {
cursor: help;
}
.hl7-field-wrap:hover {
background-color: #dbeafe;
}
`;
}