import type { Block, GlossaryBlock, GlossaryEntry, ListBlock } from "#types/block.types"; import { CATEGORIES_LABEL, DETAILS_LABEL, DIAGRAM_LABEL, OF_LABEL, SEARCH_LABEL, SEARCH_PLACEHOLDER, SHOWN_LABEL, } from "#configuration/strings/ontology.strings"; import { SEARCH_ATTRIBUTE, WIDE_FIELD_LENGTH } from "#configuration/constants/ontology.constants"; import type { Section, Subsection, Tab } from "#types/document.types"; import { appendChildren, createElement } from "#core/factories/element.factory"; import { fillMarkup, plainText } from "#presentation/renderers/text.renderer"; import { ANCHOR_PREFIX } from "#configuration/constants/document.constants"; import { createFilterBox } from "#presentation/components/filter.component"; import { isStaticRender } from "#core/predicates/document.predicate"; import { renderBlock } from "#presentation/renderers/block.renderer"; import { subsectionId } from "#presentation/renderers/section.renderer"; const SPACE = " "; const RECORD_CLASS = "record"; const GROUP_CLASS = "explorer-group"; const FIELD_CLASS = "record-field"; const WIDE_FIELD_CLASS = "record-field-wide"; const TOGGLE_EVENT = "toggle"; const isList = function isList(block: Block): block is ListBlock { return block.kind === "list"; }; const isGlossary = function isGlossary(block: Block): block is GlossaryBlock { return block.kind === "glossary"; }; const DIAGRAM_KINDS: ReadonlySet = new Set(["mermaid", "walk"]); const isDiagram = function isDiagram(block: Block): boolean { return DIAGRAM_KINDS.has(block.kind); }; const haystack = function haystack(parts: readonly string[]): string { return parts.map(plainText).join(SPACE).toLowerCase(); }; const renderChips = function renderChips(items: readonly string[]): Element { const list = createElement("ul", { className: "record-chips" }); appendChildren( list, items.map((item) => fillMarkup(createElement("li", { className: "record-chip" }), item)), ); return list; }; const renderDetails = function renderDetails(label: string, build: () => readonly Element[]): Element { const details = createElement("details", { children: [createElement("summary", { className: "record-summary", text: label })], className: "record-details", }); if (isStaticRender()) { appendChildren(details, build()); return details; } details.addEventListener( TOGGLE_EVENT, () => { appendChildren(details, build()); }, { once: true }, ); return details; }; const renderRecord = function renderRecord( id: string, title: string, chips: readonly string[], body: (() => readonly Element[]) | null, searchable: readonly string[], ): Element { const header = createElement("header", { children: [ createElement("h3", { className: "record-title", text: title }), ...(chips.length === 0 ? [] : [renderChips(chips)]), ], className: "record-header", }); const children = body === null ? [header] : [header, renderDetails(DETAILS_LABEL, body)]; return createElement("article", { attributes: { id, [SEARCH_ATTRIBUTE]: haystack([title, ...chips, ...searchable]) }, children, className: RECORD_CLASS, }); }; const entryRecord = function entryRecord(entry: GlossaryEntry, index: number, prefix: string): Element { const body = (): readonly Element[] => [ fillMarkup(createElement("p", { className: "record-text" }), entry.description), ]; return renderRecord(entry.id ?? subsectionId(prefix, index), entry.term, [], body, [entry.description]); }; const fieldClass = function fieldClass(entry: GlossaryEntry): string { return plainText(entry.description).length > WIDE_FIELD_LENGTH ? `${FIELD_CLASS} ${WIDE_FIELD_CLASS}` : FIELD_CLASS; }; export const renderFields = function renderFields(block: GlossaryBlock): Element { const cards = block.entries.map((entry) => createElement("div", { children: [ createElement("span", { className: "record-field-label", text: entry.term }), fillMarkup(createElement("div", { className: "record-field-value" }), entry.description), ], className: fieldClass(entry), }), ); return createElement("div", { children: cards, className: "record-fields" }); }; const renderBody = function renderBody(block: Block): Element { return isGlossary(block) ? renderFields(block) : renderBlock(block); }; const subsectionRecords = function subsectionRecords( sub: Subsection, sectionId: string, index: number, ): readonly Element[] { const blocks = sub.blocks ?? []; const [first] = blocks; if (sub.id === undefined && blocks.length === 1 && first !== undefined && isGlossary(first)) { return first.entries.map((entry, at) => entryRecord(entry, at, subsectionId(sectionId, index))); } const chips = first !== undefined && isList(first) ? first.items : []; const rest = blocks.filter((block) => block !== first || !isList(block)); const searchable = blocks.flatMap((block) => isGlossary(block) ? block.entries.map((entry) => entry.term + SPACE + entry.description) : [], ); const body = rest.length === 0 ? null : (): readonly Element[] => rest.map(renderBody); return [renderRecord(subsectionId(sectionId, index, sub.id), sub.title, chips, body, searchable)]; }; const renderDiagrams = function renderDiagrams(blocks: readonly Block[]): readonly Element[] { const diagrams = blocks.filter(isDiagram); if (diagrams.length === 0) { return []; } const panels = (): readonly Element[] => diagrams.map((block) => createElement("div", { children: [renderBlock(block)], className: "chapter-panel" })); return [renderDetails(DIAGRAM_LABEL, panels)]; }; const renderGroup = function renderGroup(section: Section): Element { const records = section.subsections.flatMap((sub, index) => subsectionRecords(sub, section.id, index)); const group = createElement("section", { attributes: { id: section.id }, className: GROUP_CLASS }); group.append(createElement("h2", { className: "explorer-group-title", text: section.title })); if (section.intro !== undefined) { group.append(fillMarkup(createElement("p", { className: "explorer-intro" }), section.intro)); } appendChildren(group, (section.blocks ?? []).filter((block) => !isDiagram(block)).map(renderBlock)); appendChildren(group, renderDiagrams(section.blocks ?? [])); group.append(createElement("div", { children: records, className: "explorer-records" })); return group; }; const renderNav = function renderNav(sections: readonly Section[]): Element { const list = createElement("ul", { className: "explorer-nav-list" }); appendChildren( list, sections.map((section) => createElement("li", { children: [ createElement("a", { attributes: { href: ANCHOR_PREFIX + section.id }, text: section.title }), ], }), ), ); return createElement("nav", { children: [createElement("h2", { className: "explorer-nav-title", text: CATEGORIES_LABEL }), list], className: "explorer-nav", }); }; const applyQuery = function applyQuery(root: Element, count: Element, query: string): void { const records = [...root.querySelectorAll(`.${RECORD_CLASS}`)]; let shown = 0; for (const record of records) { const visible = query.length === 0 || (record.getAttribute(SEARCH_ATTRIBUTE) ?? "").includes(query); record.hidden = !visible; shown += visible ? 1 : 0; } for (const group of root.querySelectorAll(`.${GROUP_CLASS}`)) { const held = group.querySelector(`.${RECORD_CLASS}`) !== null; group.hidden = held && group.querySelector(`.${RECORD_CLASS}:not([hidden])`) === null; } count.textContent = [String(shown), OF_LABEL, String(records.length), SHOWN_LABEL].join(SPACE); }; export const renderExplorer = function renderExplorer(tab: Tab): Element { const groups = createElement("div", { children: tab.sections.map(renderGroup), className: "explorer-groups" }); const count = createElement("span", { className: "explorer-count" }); const search = createFilterBox(SEARCH_PLACEHOLDER, SEARCH_LABEL, (query) => { applyQuery(groups, count, query); }); const toolbar = createElement("div", { children: [search, count], className: "explorer-toolbar" }); const body = createElement("div", { children: [renderNav(tab.sections), groups], className: "explorer-body" }); applyQuery(groups, count, ""); return createElement("article", { children: [createElement("h1", { className: "explorer-title", text: tab.label }), toolbar, body], className: "explorer", }); };