Render structured list and table entries in stat block traits
All checks were successful
CI / check (push) Successful in 2m21s
CI / build-image (push) Successful in 17s

Stat block traits containing 5etools list (e.g. Confusing Burble d4
effects) or table entries were silently dropped. The adapter now
produces structured TraitSegment[] instead of flat text, preserving
lists and tables as first-class data. The stat block component renders
labeled list items inline (bold label + flowing text) matching the
5etools layout. Also fixes support for the singular "entry" field on
list items and bumps the bestiary cache version to force re-normalize.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Lukas
2026-04-04 22:09:11 +02:00
parent 817cfddabc
commit 8f6eebc43b
7 changed files with 335 additions and 37 deletions

View File

@@ -5,6 +5,8 @@ import type {
LegendaryBlock,
SpellcastingBlock,
TraitBlock,
TraitListItem,
TraitSegment,
} from "@initiative/domain";
import { creatureId, proficiencyBonus } from "@initiative/domain";
import { stripTags } from "./strip-tags.js";
@@ -63,11 +65,18 @@ interface RawEntryObject {
type: string;
items?: (
| string
| { type: string; name?: string; entries?: (string | RawEntryObject)[] }
| {
type: string;
name?: string;
entry?: string;
entries?: (string | RawEntryObject)[];
}
)[];
style?: string;
name?: string;
entries?: (string | RawEntryObject)[];
colLabels?: string[];
rows?: (string | RawEntryObject)[][];
}
interface RawSpellcasting {
@@ -257,23 +266,34 @@ function formatConditionImmunities(
.join(", ");
}
function renderListItem(item: string | RawEntryObject): string | undefined {
function toListItem(
item:
| string
| {
type: string;
name?: string;
entry?: string;
entries?: (string | RawEntryObject)[];
},
): TraitListItem | undefined {
if (typeof item === "string") {
return `${stripTags(item)}`;
return { text: stripTags(item) };
}
if (item.name && item.entries) {
return `${stripTags(item.name)}: ${renderEntries(item.entries)}`;
return { label: stripTags(item.name), text: renderEntries(item.entries) };
}
if (item.name && item.entry) {
return { label: stripTags(item.name), text: stripTags(item.entry) };
}
return undefined;
}
function renderEntryObject(entry: RawEntryObject, parts: string[]): void {
if (entry.type === "list") {
for (const item of entry.items ?? []) {
const rendered = renderListItem(item);
if (rendered) parts.push(rendered);
}
} else if (entry.type === "item" && entry.name && entry.entries) {
if (entry.type === "list" || entry.type === "table") {
// Handled structurally in segmentizeEntries
return;
}
if (entry.type === "item" && entry.name && entry.entries) {
parts.push(`${stripTags(entry.name)}: ${renderEntries(entry.entries)}`);
} else if (entry.entries) {
parts.push(renderEntries(entry.entries));
@@ -292,11 +312,67 @@ function renderEntries(entries: (string | RawEntryObject)[]): string {
return parts.join(" ");
}
function tableRowToListItem(row: (string | RawEntryObject)[]): TraitListItem {
return {
label: typeof row[0] === "string" ? stripTags(row[0]) : undefined,
text: row
.slice(1)
.map((cell) =>
typeof cell === "string" ? stripTags(cell) : renderEntries([cell]),
)
.join(" "),
};
}
function entryToListSegment(entry: RawEntryObject): TraitSegment | undefined {
if (entry.type === "list") {
const items = (entry.items ?? [])
.map(toListItem)
.filter((i): i is TraitListItem => i !== undefined);
return items.length > 0 ? { type: "list", items } : undefined;
}
if (entry.type === "table" && entry.rows) {
const items = entry.rows.map(tableRowToListItem);
return items.length > 0 ? { type: "list", items } : undefined;
}
return undefined;
}
function segmentizeEntries(
entries: (string | RawEntryObject)[],
): TraitSegment[] {
const segments: TraitSegment[] = [];
const textParts: string[] = [];
const flushText = () => {
if (textParts.length > 0) {
segments.push({ type: "text", value: textParts.join(" ") });
textParts.length = 0;
}
};
for (const entry of entries) {
if (typeof entry === "string") {
textParts.push(stripTags(entry));
continue;
}
const listSeg = entryToListSegment(entry);
if (listSeg) {
flushText();
segments.push(listSeg);
} else {
renderEntryObject(entry, textParts);
}
}
flushText();
return segments;
}
function normalizeTraits(raw?: RawEntry[]): TraitBlock[] | undefined {
if (!raw || raw.length === 0) return undefined;
return raw.map((t) => ({
name: stripTags(t.name),
text: renderEntries(t.entries),
segments: segmentizeEntries(t.entries),
}));
}
@@ -361,7 +437,7 @@ function normalizeLegendary(
preamble,
entries: raw.map((e) => ({
name: stripTags(e.name),
text: renderEntries(e.entries),
segments: segmentizeEntries(e.entries),
})),
};
}