394 lines
19 KiB
TypeScript
394 lines
19 KiB
TypeScript
// SPEC: PJ2026-01060703 CI/CD branch follower drill-down rendering.
|
|
// Responsibility: bounded human summaries for branch-follower events/logs gates.
|
|
|
|
export function renderDrillDownHuman(payload: Record<string, unknown>): string {
|
|
if (payload.action === "taskrun") return renderTaskRunHuman(payload);
|
|
if (payload.action === "job") return renderJobHuman(payload);
|
|
if (payload.action === "runtime") return renderRuntimeHuman(payload);
|
|
if (payload.follower === undefined) {
|
|
const followers = arrayRecords(payload.followers);
|
|
return [
|
|
`CI/CD BRANCH-FOLLOWER ${String(payload.action ?? "drill-down").toUpperCase()}`,
|
|
"",
|
|
table(["FOLLOWER", "ADAPTER", "STATUS_AUTHORITY"], followers.map((item) => [item.id, item.adapter, item.statusAuthority ?? "k8s-native"])),
|
|
"",
|
|
].join("\n");
|
|
}
|
|
const summary = asOptionalRecord(payload.summary);
|
|
const native = asOptionalRecord(payload.native);
|
|
const gateRows = nativeGateRows(native);
|
|
return [
|
|
`CI/CD BRANCH-FOLLOWER ${String(payload.action ?? "drill-down").toUpperCase()} (${payload.ok === false ? "failed" : "ok"})`,
|
|
"",
|
|
table(
|
|
["FOLLOWER", "ADAPTER", "AUTHORITY", "PHASE", "OBSERVED", "TARGET", "PIPELINERUN", "MESSAGE"],
|
|
[[payload.follower, payload.adapter ?? "-", payload.statusAuthority ?? "k8s-native", summary?.phase ?? "-", shortSha(stringOrNull(summary?.observedSha)), shortSha(stringOrNull(summary?.targetSha)), summary?.pipelineRun ?? "-", summary?.message ?? "-"]],
|
|
),
|
|
gateRows.length === 0 ? "" : `\nGATES\n${table(["GATE", "STATUS", "DETAIL", "OBJECT"], gateRows)}`,
|
|
"",
|
|
].filter((line) => line !== "").join("\n");
|
|
}
|
|
|
|
function renderJobHuman(payload: Record<string, unknown>): string {
|
|
const result = asOptionalRecord(payload.result);
|
|
const job = asOptionalRecord(result?.job);
|
|
const query = asOptionalRecord(payload.query);
|
|
const policy = asOptionalRecord(payload.policy);
|
|
const pods = arrayRecords(result?.pods);
|
|
const logs = arrayRecords(result?.logs);
|
|
const errors = arrayRecords(result?.errors);
|
|
const summaryEvidence = refreshEvidenceRows(asOptionalRecord(result?.summary));
|
|
const command = asOptionalRecord(payload.command);
|
|
const identity = asOptionalRecord(command?.identity);
|
|
return [
|
|
`CI/CD BRANCH-FOLLOWER JOB (${payload.ok === false ? "failed" : "ok"})`,
|
|
"",
|
|
table(
|
|
["FOLLOWER", "ADAPTER", "STAGE", "NAMESPACE", "JOB", "STATUS", "REASON", "DURATION", "PODS"],
|
|
[[
|
|
payload.follower,
|
|
payload.adapter ?? "-",
|
|
job?.stage ?? query?.stage ?? "-",
|
|
job?.namespace ?? query?.namespace ?? "-",
|
|
job?.name ?? query?.jobName ?? "-",
|
|
jobStatus(job),
|
|
asOptionalRecord(job?.condition)?.reason ?? result?.degradedReason ?? "-",
|
|
job?.durationSeconds ?? "-",
|
|
pods.length,
|
|
]],
|
|
),
|
|
pods.length === 0 ? "" : `\nPODS\n${table(["POD", "PHASE", "READY", "START", "CONTAINERS", "REASON"], pods.map(jobPodRow))}`,
|
|
logs.length === 0 ? "" : `\nLOG TAILS\n${table(["POD", "CONTAINER", "STATUS", "REASON", "LINES", "BYTES", "TIMING", "MESSAGE"], logs.map(logRow))}`,
|
|
errors.length === 0 ? "" : `\nERRORS\n${table(["POD", "CONTAINER", "REASON", "MESSAGE"], errors.map((item) => [item.pod, item.container, item.degradedReason, item.message]))}`,
|
|
summaryEvidence.length === 0 ? "" : `\nEVIDENCE\n${table(["TYPE", "STATUS", "DETAIL", "OBJECT"], summaryEvidence)}`,
|
|
command === null ? "" : `\nTARGET COMMAND\n${table(["ROUTE", "SCRIPT", "EXIT", "PARSE_ERROR"], [[identity?.route ?? "-", identity?.script ?? "-", command.exitCode ?? "-", command.parseError ?? "-"]])}`,
|
|
command?.stdoutTail ? `\nSTDOUT_TAIL\n${command.stdoutTail}` : "",
|
|
command?.stderrTail ? `\nSTDERR_TAIL\n${command.stderrTail}` : "",
|
|
"",
|
|
`policy: tailLines=${policy?.logsTailLines ?? "-"} maxLogBytes=${policy?.maxLogBytes ?? "-"} timeoutSeconds=${policy?.timeoutSeconds ?? "-"} maxContainers=${policy?.maxContainers ?? "-"}`,
|
|
"",
|
|
].filter((line) => line !== "").join("\n");
|
|
}
|
|
|
|
function renderRuntimeHuman(payload: Record<string, unknown>): string {
|
|
const result = asOptionalRecord(payload.result);
|
|
const query = asOptionalRecord(payload.query);
|
|
const policy = asOptionalRecord(payload.policy);
|
|
const workloads = arrayRecords(result?.workloads);
|
|
const pods = workloads.flatMap((workload) => arrayRecords(workload.pods).map((pod) => ({ ...pod, workload: `${workload.kind ?? "-"}/${workload.name ?? "-"}` }))).slice(0, 12);
|
|
const command = asOptionalRecord(payload.command);
|
|
const identity = asOptionalRecord(command?.identity);
|
|
return [
|
|
`CI/CD BRANCH-FOLLOWER RUNTIME (${payload.ok === false ? "failed" : "ok"})`,
|
|
"",
|
|
table(
|
|
["FOLLOWER", "ADAPTER", "NAMESPACE", "EXPECTED", "TARGET", "READY", "ALIGNED", "BLOCKING"],
|
|
[[
|
|
payload.follower,
|
|
payload.adapter ?? "-",
|
|
result?.namespace ?? query?.namespace ?? "-",
|
|
shortSha(stringOrNull(result?.expectedSha)),
|
|
shortSha(stringOrNull(result?.targetSha)),
|
|
result?.ready ?? "-",
|
|
result?.aligned ?? "-",
|
|
result?.blockingReason ?? "-",
|
|
]],
|
|
),
|
|
workloads.length === 0 ? "" : `\nWORKLOADS\n${table(["KIND", "NAME", "READY", "ALIGNED", "REPLICAS", "UPDATED", "SOURCE", "BLOCKING"], workloads.map(runtimeWorkloadRow))}`,
|
|
pods.length === 0 ? "" : `\nPODS\n${table(["WORKLOAD", "POD", "PHASE", "READY", "START", "SOURCE", "CONTAINERS"], pods.map(runtimePodRow))}`,
|
|
command === null ? "" : `\nTARGET COMMAND\n${table(["ROUTE", "SCRIPT", "EXIT", "PARSE_ERROR"], [[identity?.route ?? "-", identity?.script ?? "-", command.exitCode ?? "-", command.parseError ?? "-"]])}`,
|
|
command?.stdoutTail ? `\nSTDOUT_TAIL\n${command.stdoutTail}` : "",
|
|
command?.stderrTail ? `\nSTDERR_TAIL\n${command.stderrTail}` : "",
|
|
"",
|
|
`policy: timeoutSeconds=${policy?.timeoutSeconds ?? "-"} maxContainers=${policy?.maxContainers ?? "-"}`,
|
|
"",
|
|
].filter((line) => line !== "").join("\n");
|
|
}
|
|
|
|
function renderTaskRunHuman(payload: Record<string, unknown>): string {
|
|
const result = asOptionalRecord(payload.result);
|
|
const taskRun = asOptionalRecord(result?.taskRun);
|
|
const pod = asOptionalRecord(result?.pod);
|
|
const policy = asOptionalRecord(payload.policy);
|
|
const containers = arrayRecords(result?.containers);
|
|
const logs = arrayRecords(result?.logs);
|
|
const errors = arrayRecords(result?.errors);
|
|
const timing = asOptionalRecord(result?.nodeCicdTiming);
|
|
const query = asOptionalRecord(payload.query);
|
|
const command = asOptionalRecord(payload.command);
|
|
const identity = asOptionalRecord(command?.identity);
|
|
return [
|
|
`CI/CD BRANCH-FOLLOWER TASKRUN (${payload.ok === false ? "failed" : "ok"})`,
|
|
"",
|
|
table(
|
|
["FOLLOWER", "ADAPTER", "TASKRUN", "PIPELINERUN", "POD", "STATUS", "REASON", "DURATION", "CONTAINERS"],
|
|
[[
|
|
payload.follower,
|
|
payload.adapter ?? "-",
|
|
taskRun?.name ?? query?.taskRun ?? "-",
|
|
taskRun?.pipelineRun ?? query?.pipelineRun ?? "-",
|
|
taskRun?.podName ?? "-",
|
|
asOptionalRecord(taskRun?.condition)?.status ?? "-",
|
|
asOptionalRecord(taskRun?.condition)?.reason ?? "-",
|
|
taskRun?.durationSeconds ?? "-",
|
|
pod?.containerCount ?? "-",
|
|
]],
|
|
),
|
|
containers.length === 0 ? "" : `\nCONTAINERS\n${table(["NAME", "CONTAINER", "STATE", "REASON", "EXIT", "STARTED", "FINISHED", "MESSAGE"], containers.map(containerRow))}`,
|
|
logs.length === 0 ? "" : `\nLOG TAILS\n${table(["POD", "CONTAINER", "STATUS", "REASON", "LINES", "BYTES", "TIMING", "MESSAGE"], logs.map(logRow))}`,
|
|
errors.length === 0 ? "" : `\nERRORS\n${table(["POD", "CONTAINER", "REASON", "MESSAGE"], errors.map((item) => [item.pod, item.container, item.degradedReason, item.message]))}`,
|
|
timing === null ? "" : `\nNODE_CICD_TIMING\n${JSON.stringify(timing, null, 2)}`,
|
|
command === null ? "" : `\nTARGET COMMAND\n${table(["ROUTE", "SCRIPT", "NAMESPACE", "TASKRUN", "PIPELINERUN", "EXIT", "PARSE_ERROR"], [[identity?.route ?? "-", identity?.script ?? "-", identity?.namespace ?? "-", identity?.taskRun ?? "-", identity?.pipelineRun ?? "-", command.exitCode ?? "-", command.parseError ?? "-"]])}`,
|
|
command?.stdoutTail ? `\nSTDOUT_TAIL\n${command.stdoutTail}` : "",
|
|
command?.stderrTail ? `\nSTDERR_TAIL\n${command.stderrTail}` : "",
|
|
"",
|
|
`policy: tailLines=${policy?.logsTailLines ?? "-"} maxLogBytes=${policy?.maxLogBytes ?? "-"} timeoutSeconds=${policy?.taskRunTimeoutSeconds ?? "-"} maxContainers=${policy?.maxContainers ?? "-"}`,
|
|
"",
|
|
].filter((line) => line !== "").join("\n");
|
|
}
|
|
|
|
function jobStatus(job: Record<string, unknown> | null): string {
|
|
if (job === null) return "-";
|
|
if (job.completed === true) return "completed";
|
|
if (job.failedState === true) return "failed";
|
|
if (numberOrNull(job.active) !== null && numberOrNull(job.active)! > 0) return "active";
|
|
return stringOrNull(asOptionalRecord(job.condition)?.type) ?? "-";
|
|
}
|
|
|
|
function jobPodRow(item: Record<string, unknown>): unknown[] {
|
|
return [
|
|
item.name,
|
|
item.phase,
|
|
item.ready,
|
|
item.startTime ?? item.createdAt ?? "-",
|
|
item.containerCount,
|
|
item.reason ?? "-",
|
|
];
|
|
}
|
|
|
|
function runtimeWorkloadRow(item: Record<string, unknown>): unknown[] {
|
|
const sourceCommit = asOptionalRecord(item.sourceCommit);
|
|
return [
|
|
item.kind,
|
|
item.name,
|
|
item.ready,
|
|
item.aligned,
|
|
`${item.readyReplicas ?? "-"}/${item.replicas ?? "-"}`,
|
|
item.updatedReplicas ?? "-",
|
|
shortSha(stringOrNull(sourceCommit?.value)),
|
|
item.blockingReason ?? "-",
|
|
];
|
|
}
|
|
|
|
function runtimePodRow(item: Record<string, unknown>): unknown[] {
|
|
const sourceCommit = asOptionalRecord(item.sourceCommit);
|
|
return [
|
|
item.workload,
|
|
item.name,
|
|
item.phase,
|
|
item.ready,
|
|
item.startTime ?? item.createdAt ?? "-",
|
|
shortSha(stringOrNull(sourceCommit?.value)),
|
|
arrayRecords(item.containers).length,
|
|
];
|
|
}
|
|
|
|
function logRow(item: Record<string, unknown>): unknown[] {
|
|
return [
|
|
item.pod,
|
|
item.container,
|
|
item.ok === false ? "failed" : "ok",
|
|
item.degradedReason ?? "-",
|
|
item.lineCount,
|
|
item.bytes,
|
|
asOptionalRecord(item.nodeCicdTiming) === null ? "-" : "node-cicd-timing",
|
|
item.message ?? "-",
|
|
];
|
|
}
|
|
|
|
function containerRow(item: Record<string, unknown>): unknown[] {
|
|
const terminated = asOptionalRecord(item.terminated);
|
|
const waiting = asOptionalRecord(item.waiting);
|
|
const running = asOptionalRecord(item.running);
|
|
const state = terminated !== null ? "terminated" : waiting !== null ? "waiting" : running !== null ? "running" : "-";
|
|
return [
|
|
item.name,
|
|
item.containerName,
|
|
state,
|
|
terminated?.reason ?? waiting?.reason ?? "-",
|
|
terminated?.exitCode ?? "-",
|
|
terminated?.startedAt ?? running?.startedAt ?? "-",
|
|
terminated?.finishedAt ?? "-",
|
|
terminated?.message ?? waiting?.message ?? "-",
|
|
];
|
|
}
|
|
|
|
function nativeGateRows(native: Record<string, unknown> | null): unknown[][] {
|
|
if (native === null) return [];
|
|
const rows: unknown[][] = [];
|
|
const gitMirror = asOptionalRecord(native.gitMirror);
|
|
if (gitMirror !== null) {
|
|
const hasGitops = stringOrNull(gitMirror.gitopsBranch) !== null;
|
|
const status = gitMirror.pendingFlush === true
|
|
? "pending-flush"
|
|
: hasGitops
|
|
? gitMirror.githubInSync === true && gitMirror.sourceSnapshotReady === true ? "ready" : "not-ready"
|
|
: gitMirror.sourceSnapshotReady === true ? "source-ready" : "source-not-ready";
|
|
rows.push(["git-mirror", status, `${shortSha(stringOrNull(gitMirror.localSource))}/${shortSha(stringOrNull(gitMirror.githubSource))}`, stringOrNull(gitMirror.gitopsBranch) ?? stringOrNull(gitMirror.sourceBranch) ?? "-"]);
|
|
}
|
|
const reuseConfig = asOptionalRecord(native.reuseConfig);
|
|
if (reuseConfig !== null) {
|
|
const status = reuseConfig.ok === true ? "ready" : reuseConfig.present === true ? "invalid" : "missing";
|
|
const detail = reuseConfig.ok === true ? `${reuseConfig.serviceCount ?? "-"} services` : arrayTextItems(reuseConfig.errors)[0] ?? "-";
|
|
rows.push(["reuse-config", status, detail, stringOrNull(reuseConfig.path) ?? "-"]);
|
|
}
|
|
const tekton = asOptionalRecord(native.tekton);
|
|
if (tekton !== null) {
|
|
const status = tekton.succeeded === true ? "succeeded" : tekton.succeeded === false ? `failed:${stringOrNull(tekton.reason) ?? "unknown"}` : "running";
|
|
const duration = numberOrNull(tekton.durationSeconds);
|
|
rows.push(["tekton", status, duration === null ? stringOrNull(tekton.reason) ?? "-" : `${duration}s`, stringOrNull(tekton.name) ?? "-"]);
|
|
}
|
|
const taskRuns = asOptionalRecord(native.taskRuns);
|
|
if (taskRuns !== null) {
|
|
const failed = arrayRecords(taskRuns.failedItems)[0];
|
|
const active = arrayRecords(taskRuns.activeItems)[0];
|
|
const slow = arrayRecords(taskRuns.slowItems)[0];
|
|
const detail = taskRunDetail(failed ?? active ?? slow);
|
|
const status = numberOrNull(taskRuns.failedCount) !== null && numberOrNull(taskRuns.failedCount)! > 0
|
|
? `failed:${numberOrNull(taskRuns.failedCount)}`
|
|
: numberOrNull(taskRuns.activeCount) !== null && numberOrNull(taskRuns.activeCount)! > 0
|
|
? `active:${numberOrNull(taskRuns.activeCount)}`
|
|
: "ok";
|
|
rows.push(["taskruns", status, detail, "-"]);
|
|
}
|
|
const argo = asOptionalRecord(native.argo);
|
|
if (argo !== null) {
|
|
rows.push(["argo", `${stringOrNull(argo.syncStatus) ?? "unknown"}/${stringOrNull(argo.healthStatus) ?? "unknown"}`, argoDetail(argo), stringOrNull(argo.name) ?? "-"]);
|
|
}
|
|
const runtime = asOptionalRecord(native.runtime);
|
|
if (runtime !== null) {
|
|
const status = runtime.ready === true ? (runtime.aligned === true ? "ready/aligned" : "ready/stale") : "not-ready";
|
|
rows.push(["runtime", status, `${shortSha(stringOrNull(runtime.targetSha))}/${shortSha(stringOrNull(runtime.expectedSha))}`, stringOrNull(runtime.namespace) ?? "-"]);
|
|
}
|
|
const pipeline = asOptionalRecord(native.pipeline);
|
|
if (pipeline !== null) {
|
|
const runtimeReady = asOptionalRecord(asOptionalRecord(pipeline.spec)?.runtimeReadyTask);
|
|
const when = arrayRecords(runtimeReady?.when)[0];
|
|
rows.push([
|
|
"pipeline",
|
|
runtimeReady?.present === true ? "runtime-ready-present" : "runtime-ready-absent",
|
|
when === undefined ? "-" : `${stringOrNull(when.input) ?? "-"} ${stringOrNull(when.operator) ?? "-"} ${arrayTextItems(when.values).join(",") || "-"}`,
|
|
stringOrNull(asOptionalRecord(pipeline.metadata)?.name) ?? "-",
|
|
]);
|
|
}
|
|
const refresh = asOptionalRecord(native.refreshEvidence);
|
|
if (refresh !== null) {
|
|
rows.push([
|
|
"control-plane-refresh",
|
|
stringOrNull(refresh.status) ?? "-",
|
|
`${shortSha(stringOrNull(refresh.sourceCommit))}/${stringOrNull(refresh.pipeline) ?? "-"}`,
|
|
stringOrNull(refresh.jobName) ?? "-",
|
|
]);
|
|
rows.push(...refreshEvidenceRows(refresh));
|
|
}
|
|
for (const error of arrayTextItems(native.errors).slice(0, 5)) rows.push(["error", "present", error, "-"]);
|
|
return rows;
|
|
}
|
|
|
|
function refreshEvidenceRows(value: Record<string, unknown> | null): unknown[][] {
|
|
if (value === null) return [];
|
|
const rows: unknown[][] = [];
|
|
const render = asOptionalRecord(value.render);
|
|
const renderRuntimeReady = asOptionalRecord(render?.runtimeReadyTask);
|
|
if (render !== null) {
|
|
rows.push([
|
|
"control-plane-render",
|
|
renderRuntimeReady?.present === true ? "runtime-ready-present" : renderRuntimeReady?.present === false ? "runtime-ready-absent" : "-",
|
|
whenSummary(arrayRecords(renderRuntimeReady?.when)[0]),
|
|
stringOrNull(render.pipelineName) ?? "-",
|
|
]);
|
|
}
|
|
const apply = asOptionalRecord(value.apply);
|
|
if (apply !== null) {
|
|
rows.push([
|
|
"control-plane-apply",
|
|
stringOrNull(apply.resourceVersion) ?? stringOrNull(apply.degradedReason) ?? "-",
|
|
applyMetadataSummary(apply),
|
|
stringOrNull(apply.pipelineName) ?? "-",
|
|
]);
|
|
}
|
|
return rows;
|
|
}
|
|
|
|
function taskRunDetail(item: Record<string, unknown> | undefined): string {
|
|
if (item === undefined) return "-";
|
|
const duration = numberOrNull(item.durationSeconds);
|
|
return `${item.pipelineTask ?? item.name ?? "task"} ${item.reason ?? item.status ?? "-"}${duration === null ? "" : ` ${duration}s`}`;
|
|
}
|
|
|
|
function argoDetail(argo: Record<string, unknown>): string {
|
|
const resource = arrayRecords(argo.nonReadyResources)[0];
|
|
const condition = arrayRecords(argo.conditions)[0];
|
|
return stringOrNull(argo.healthMessage)
|
|
?? stringOrNull(argo.operationMessage)
|
|
?? (resource === undefined ? null : `${resource.kind ?? "resource"}/${resource.name ?? "-"} ${asOptionalRecord(resource.health)?.status ?? resource.healthStatus ?? "-"}`)
|
|
?? (condition === undefined ? null : `${condition.type ?? "condition"} ${condition.message ?? ""}`.trim())
|
|
?? shortSha(stringOrNull(argo.revision));
|
|
}
|
|
|
|
function whenSummary(value: Record<string, unknown> | undefined): string {
|
|
if (value === undefined) return "-";
|
|
const values = arrayTextItems(value.values).join(",");
|
|
return `${stringOrNull(value.input) ?? "-"} ${stringOrNull(value.operator) ?? "-"} ${values || "-"}`;
|
|
}
|
|
|
|
function applyMetadataSummary(value: Record<string, unknown>): string {
|
|
const annotations = asOptionalRecord(value.annotations);
|
|
const labels = asOptionalRecord(value.labels);
|
|
return `ann:${firstEntry(annotations)} label:${firstEntry(labels)}`;
|
|
}
|
|
|
|
function firstEntry(value: Record<string, unknown> | null): string {
|
|
if (value === null) return "-";
|
|
const [key, item] = Object.entries(value)[0] ?? [];
|
|
return key === undefined ? "-" : `${key}=${stringOrNull(item) ?? "-"}`;
|
|
}
|
|
|
|
function asOptionalRecord(value: unknown): Record<string, unknown> | null {
|
|
return typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record<string, unknown> : null;
|
|
}
|
|
|
|
function arrayRecords(value: unknown): Record<string, unknown>[] {
|
|
return Array.isArray(value) ? value.filter((item): item is Record<string, unknown> => typeof item === "object" && item !== null && !Array.isArray(item)) : [];
|
|
}
|
|
|
|
function arrayTextItems(value: unknown): string[] {
|
|
return Array.isArray(value) ? value.map(String) : [];
|
|
}
|
|
|
|
function stringOrNull(value: unknown): string | null {
|
|
return typeof value === "string" && value.length > 0 ? value : null;
|
|
}
|
|
|
|
function numberOrNull(value: unknown): number | null {
|
|
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
|
}
|
|
|
|
function shortSha(value: string | null): string {
|
|
if (value === null) return "-";
|
|
return value.length > 12 ? value.slice(0, 12) : value;
|
|
}
|
|
|
|
function table(headers: readonly string[], rows: readonly (readonly unknown[])[]): string {
|
|
const normalized = rows.map((row) => headers.map((_, index) => cell(row[index])));
|
|
const widths = headers.map((header, index) => Math.max(header.length, ...normalized.map((row) => row[index]?.length ?? 0)));
|
|
const format = (row: readonly string[]) => row.map((value, index) => value.padEnd(widths[index] ?? 0)).join(" ").trimEnd();
|
|
return [format(headers), format(headers.map((header) => "-".repeat(header.length))), ...normalized.map(format)].join("\n");
|
|
}
|
|
|
|
function cell(value: unknown): string {
|
|
if (value === null || value === undefined || value === "") return "-";
|
|
const text = String(value).replace(/\s+/gu, " ");
|
|
return text.length > 96 ? `${text.slice(0, 93)}...` : text;
|
|
}
|