presentation/renderers/ontology.renderer.ts

presentation/renderers/ontology.renderer.ts is a file in Bane's Lab Site. 185 lines of code and 51 definitions.

import { ANCHOR_PREFIX, SPACE } from "#configuration/constants/document.constants";
import type { Block, GlossaryBlock, GlossaryEntry, ListBlock } from "#types/block.types";
import {
    CATEGORIES_LABEL,
    DIAGRAM_LABEL,
    OF_LABEL,
    SEARCH_LABEL,
    SEARCH_PLACEHOLDER,
    SHOWN_LABEL,
    sectionPageLabel,
} from "#configuration/strings/ontology.strings";
import { RECORD_CLASS, 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 { renderDetails, renderRecordRow } from "#presentation/renderers/record.renderer";
import { ONTOLOGY_PAGE } from "#core/ids/page.ids";
import { createFilterBox } from "#presentation/components/filter.component";
import { isStaticRender } from "#core/predicates/document.predicate";
import { nodePath } from "#core/assets/link.assets";
import { recordPageOf } from "#domain/converters/ontology.converter";
import { renderBlock } from "#presentation/renderers/block.renderer";
import { subsectionId } from "#presentation/renderers/section.renderer";

const GROUP_CLASS = "explorer-group";
const FIELD_CLASS = "record-field";
const WIDE_FIELD_CLASS = "record-field-wide";

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<Block["kind"]> = new Set<Block["kind"]>(["mermaid", "walk"]);

const isDiagram = function isDiagram(block: Block): boolean {
    return DIAGRAM_KINDS.has(block.kind);
};

const entryRecord = function entryRecord(entry: GlossaryEntry, index: number, section: Section): Element {
    const body = (): readonly Element[] => [
        fillMarkup(createElement("p", { className: "record-text" }), entry.description),
    ];
    const id = entry.id ?? subsectionId(section.id, index);
    return renderRecordRow({ context: section.title, id, title: entry.term }, [], body, [entry.description]);
};

const fieldClass = function fieldClass(entry: GlossaryEntry): string {
    return entry.wide === true || 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, section: Section, index: number): readonly Element[] {
    const blocks = sub.blocks ?? [];
    const [first] = blocks;
    if (sub.id === undefined && blocks.length === 1 && first !== undefined && isGlossary(first)) {
        const parent = { ...section, id: subsectionId(section.id, index) };
        return first.entries.map((entry, at) => entryRecord(entry, at, parent));
    }
    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);
    const row = { context: section.title, id: subsectionId(section.id, index, sub.id), title: sub.title };
    return [renderRecordRow(row, 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 recordsOf = function recordsOf(section: Section): readonly Element[] {
    return section.subsections.flatMap((sub, index) => subsectionRecords(sub, section, index));
};

export const rowWeights = function rowWeights(section: Section): readonly number[] {
    return section.subsections.flatMap((sub) => {
        const [first] = sub.blocks ?? [];
        const entries = sub.id === undefined && sub.blocks?.length === 1 && first !== undefined && isGlossary(first);
        return entries ? first.entries.map((entry) => JSON.stringify(entry).length) : [JSON.stringify(sub).length];
    });
};

export const rowCount = function rowCount(section: Section): number {
    return rowWeights(section).length;
};

const sectionLink = function sectionLink(tab: Tab, section: Section): readonly Element[] {
    const count = rowCount(section);
    if (count === 0) {
        return [];
    }
    const href = recordPageOf(section.id) ?? nodePath(ONTOLOGY_PAGE, tab.id, section.id);
    const link = createElement("a", { attributes: { href }, text: sectionPageLabel(count, section.title) });
    return [createElement("p", { children: [link], className: "explorer-section-link" })];
};

const renderGroup = function renderGroup(section: Section, records: readonly Element[], whole = true): Element {
    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));
    }
    if (whole) {
        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<HTMLElement>(`.${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<HTMLElement>(`.${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 renderExplorerSection = function renderExplorerSection(
    tab: Tab,
    id: string,
    rows?: readonly [number, number],
): Element | null {
    const section = tab.sections.find((candidate) => candidate.id === id);
    if (section === undefined) {
        return null;
    }
    const records = recordsOf(section);
    return renderGroup(section, rows === undefined ? records : records.slice(...rows));
};

export const renderExplorer = function renderExplorer(tab: Tab): Element {
    const linked = isStaticRender();
    const children = tab.sections.map((section) =>
        linked ? renderGroup(section, sectionLink(tab, section), rowCount(section) === 0) : renderGroup(section, recordsOf(section)),
    );
    const groups = createElement("div", { children, className: "explorer-groups" });
    const body = createElement("div", { children: [renderNav(tab.sections), groups], className: "explorer-body" });
    const title = createElement("h2", { className: "explorer-title", text: tab.label });
    if (linked) {
        return createElement("article", { children: [title, body], className: "explorer" });
    }
    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" });
    applyQuery(groups, count, "");
    return createElement("article", { children: [title, toolbar, body], className: "explorer" });
};