UNPKG

@discoveryjs/discovery

Version:

Frontend framework for rapid data (JSON) analysis, shareable serverless reports and dashboards

711 lines (710 loc) 23.4 kB
import { hasOwn } from "./utils/object-utils.js"; import { Dictionary } from "./dict.js"; import { queryToConfig } from "./utils/query-to-config.js"; ; const STUB_VIEW_OPTIONS = Object.freeze({ type: void 0 }); const STUB_CONFIG = Object.freeze({ view: "" }); const configTransitions = /* @__PURE__ */ new WeakMap(); const propsTransitions = /* @__PURE__ */ new WeakMap(); const configOnlyProps = /* @__PURE__ */ new Set([ "view", "when", "context", "data", "whenData", "postRender" ]); export function isRawViewConfig(value) { return typeof value === "string" || Array.isArray(value) || value !== null && typeof value === "object" && typeof value.view === "string"; } function regConfigTransition(res, from) { configTransitions.set(res, from); return res; } function createDefaultRenderErrorView() { return { name: "config-error", options: STUB_VIEW_OPTIONS, render(node, config) { node.appendText(`[Error: ${config.reason}]`); if ("config" in config) { } } }; } function condition(type, viewRenderer, config, queryData, context, inputData, inputDataIndex, placeholder) { if (!hasOwn(config, type) || config[type] === void 0) { return true; } if (viewRenderer.host.queryBool(config[type] === true ? "" : config[type], queryData, context)) { return true; } viewRenderer.viewEls.set(placeholder, { skipped: type, config, inputData, inputDataIndex, data: queryData, context }); return false; } class RenderBox { type; children; border; constructor(type = "inline", nodes) { this.type = type; this.children = []; this.border = null; if (Array.isArray(nodes)) { for (const node of nodes) { this.append(node); } } } appendText(value) { this.append(new RenderText(value)); } appendInlineBlock() { const node = new RenderBox("inline-block"); this.append(node); return node; } appendBlock() { const node = new RenderBox("block"); this.append(node); return node; } appendLine() { const node = new RenderBox("line"); this.append(node); return node; } append(node) { this.children.push(node); return this; } setBorder(border) { if (!border) { this.border = null; return; } this.border = typeof border === "string" ? new Border(border, border, border, border) : Array.isArray(border) ? new Border(...border) : new Border( border.top || null, border.right || null, border.bottom || null, border.left || null ); } } class RenderText { value; constructor(value) { this.value = String(value); } } class RenderPlaceholder { node; constructor() { this.node = null; } setNode(node) { this.node = node; } } function maxLinesLength(lines, start = 0, end = lines.length - 1) { let maxLength = lines[start].length; for (let i = start + 1; i <= end; i++) { maxLength = Math.max(maxLength, lines[i].length); } return maxLength; } function truncLine(line, maxLength) { return line.length > maxLength ? line.slice(0, maxLength) : line; } function borderLR(start, mid = start, end = mid, single = start) { return (idx, total) => total === 1 ? single : idx === 0 ? start : idx + 1 === total ? end : mid; } function arrayToBorderLR(value) { if (Array.isArray(value)) { return borderLR(...value); } return value; } function borderTB(start, mid = start, end = mid) { return (m, l, r) => `${start}${"".padStart(m + l + r - start.length - end.length, mid)}${end}`; } function arrayToBorderTB(value) { if (Array.isArray(value)) { return borderTB(...value); } return value; } class Border { top; left; right; bottom; constructor(top = null, right = null, bottom = top, left = right) { this.top = top || null; this.left = left || null; this.right = right || null; this.bottom = bottom || null; } render(lines, spans, node) { const leftBorder = arrayToBorderLR(this.left); const rightBorder = arrayToBorderLR(this.right); const topBorder = arrayToBorderTB(this.top); const bottomBorder = arrayToBorderTB(this.bottom); const maxMidLength = maxLinesLength(lines); let maxLeftLength = 0; let maxRightLength = 0; if (leftBorder) { if (typeof leftBorder === "function") { const prefixes = lines.map((_, idx) => String(leftBorder(idx, lines.length) ?? "")); maxLeftLength = maxLinesLength(prefixes); lines = lines.map((line, idx) => prefixes[idx].padEnd(maxLeftLength) + line); } else { maxLeftLength = leftBorder.length; lines = lines.map((line) => leftBorder + line); } if (maxLeftLength > 0) { for (let i = 0; i < lines.length; i++) { spans[i].unshift({ length: maxLeftLength, node }); } } } if (rightBorder) { if (typeof rightBorder === "function") { const suffixes = lines.map((_, idx) => String(rightBorder(idx, lines.length) ?? "")); maxRightLength = maxLinesLength(suffixes); if (maxRightLength > 0) { for (let i = 0; i < lines.length; i++) { spans[i].push({ length: maxLeftLength + maxMidLength - lines[i].length + maxRightLength, node }); } lines = lines.map((line, idx) => line.padEnd(maxLeftLength + maxMidLength) + suffixes[idx].padStart(maxRightLength)); } } else { maxRightLength = rightBorder.length; for (let i = 0; i < lines.length; i++) { spans[i].push({ length: maxLeftLength + maxMidLength - lines[i].length + maxRightLength, node }); } lines = lines.map((line) => line.padEnd(maxLeftLength + maxMidLength) + rightBorder); } } if (topBorder || bottomBorder) { const maxBorderWidth = maxMidLength + maxLeftLength + maxRightLength; if (topBorder) { const topBorderString = typeof topBorder === "function" ? topBorder(maxMidLength, maxLeftLength, maxRightLength) || "" : "".padStart(maxBorderWidth, topBorder); if (topBorderString) { lines.unshift(truncLine(topBorderString, maxBorderWidth)); spans.unshift([{ length: lines[0].length, node }]); } } if (bottomBorder) { const bottomBorderString = typeof bottomBorder === "function" ? bottomBorder(maxMidLength, maxLeftLength, maxRightLength) || "" : "".padStart(maxBorderWidth, bottomBorder); if (bottomBorderString) { lines.push(truncLine(bottomBorderString, maxBorderWidth)); spans.push([{ length: lines[lines.length - 1].length, node }]); } } } return { spans, lines }; } } async function renderNode(viewRenderer, renderer, placeholder, config, props, data, context, inputData, inputDataIndex) { const node = new RenderBox(renderer.options.type || "inline"); if (renderer.options.border) { node.setBorder(renderer.options.border); } await renderer.render(node, props, data, context); if (typeof config.postRender === "function") { await config.postRender(node, config, data, context); } const info = { config, props, inputData, inputDataIndex, data, context }; viewRenderer.viewEls.set(node, info); placeholder.setNode(node); return placeholder; } function renderError(viewRenderer, reason, placeholder, config) { return renderNode(viewRenderer, viewRenderer.defaultRenderErrorRenderer, placeholder, STUB_CONFIG, { type: "render", reason, config }); } function createRenderContext(viewRenderer, name) { return { name, normalizeConfig: viewRenderer.normalizeConfig.bind(viewRenderer), ensureValidConfig: viewRenderer.ensureValidConfig.bind(viewRenderer), composeConfig: viewRenderer.composeConfig.bind(viewRenderer), propsFromConfig: viewRenderer.propsFromConfig.bind(viewRenderer), render: viewRenderer.render.bind(viewRenderer), listLimit: viewRenderer.listLimit.bind(viewRenderer), renderList: viewRenderer.renderList.bind(viewRenderer) }; } async function render(viewRenderer, container, config, inputData, inputDataIndex, context) { if (Array.isArray(config)) { return Promise.all(config.map( (config2) => render(viewRenderer, container, config2, inputData, inputDataIndex, context) )).then((nodes) => new RenderBox("inline", nodes)); } const queryData = inputData && typeof inputDataIndex === "number" ? inputData[inputDataIndex] : inputData; let renderer = null; switch (typeof config.view) { case "function": renderer = { name: false, options: STUB_VIEW_OPTIONS, render: config.view }; break; case "string": renderer = viewRenderer.get(config.view) || null; break; } if (!container) { container = new RenderBox(); } const placeholder = new RenderPlaceholder(); container.append(placeholder); if (!renderer) { const errorMsg = typeof config.view === "string" ? "View `" + config.view + "` is not found" : "Render is not a function"; viewRenderer.host.logger.error(errorMsg, config); return renderError(viewRenderer, errorMsg, placeholder, config); } try { if (condition("when", viewRenderer, config, queryData, context, inputData, inputDataIndex, placeholder)) { const renderContext = "context" in config ? await viewRenderer.host.query(config.context, queryData, context) : context; const renderData = "data" in config ? await viewRenderer.host.query(config.data, queryData, renderContext) : queryData; if (condition("whenData", viewRenderer, config, renderData, renderContext, inputData, inputDataIndex, placeholder)) { return await renderNode( viewRenderer, renderer, placeholder, config, viewRenderer.propsFromConfig(config, renderData, renderContext), renderData, renderContext, inputData, inputDataIndex ); } } return placeholder; } catch (e) { viewRenderer.host.logger.error("View render error:", e.message); return renderError(viewRenderer, String(e), placeholder, STUB_CONFIG); } } export class TextViewRenderer extends Dictionary { host; defaultRenderErrorRenderer; viewEls; constructor(host) { super(); this.host = host; this.defaultRenderErrorRenderer = createDefaultRenderErrorView(); this.viewEls = /* @__PURE__ */ new WeakMap(); } define(name, _render, _options) { const options = isRawViewConfig(_render) || typeof _render === "function" ? { ..._options, render: _render } : _render; const { render: render2 = [], ...optionsWithoutRender } = options; const { type, props } = optionsWithoutRender; return TextViewRenderer.define(this, name, Object.freeze({ name, options: Object.freeze({ ...options, type: typeof type === "string" || type === void 0 ? type : void 0, props: typeof props === "string" ? this.host.queryFn(props) : props }), render: typeof render2 === "function" ? render2.bind(createRenderContext(this, name)) : (node, _, data, context) => this.render(node, render2, data, context) })); } normalizeConfig(config) { if (!config) { return null; } if (Array.isArray(config)) { const arrayOfConfigs = []; for (const configElement of config) { const normalizedConfig = this.normalizeConfig(configElement); if (normalizedConfig !== null) { if (Array.isArray(normalizedConfig)) { arrayOfConfigs.push(...normalizedConfig); } else { arrayOfConfigs.push(normalizedConfig); } } } return arrayOfConfigs; } if (typeof config === "string") { const [, prefix, op, query] = config.match(/^(\S+?)([:{])((?:.|\s)+)$/) || []; if (prefix) { if (op === "{") { try { return regConfigTransition( queryToConfig(prefix, op + query), // FIXME config ); } catch (error) { return regConfigTransition( this.badConfig(config, error), config ); } } return regConfigTransition({ view: prefix, data: query }, config); } return regConfigTransition({ view: config }, config); } else if (typeof config === "function") { return regConfigTransition({ view: config }, config); } return config; } badConfig(config, error) { const errorMsg = error?.message || "Unknown error"; this.host.logger.error(errorMsg, { config, error }); return { view: this.defaultRenderErrorRenderer.render, type: "config", reason: errorMsg, config }; } ensureValidConfig(config) { if (Array.isArray(config)) { return config.map((item) => this.ensureValidConfig(item)).flat(); } if (!config || !config.view) { return this.badConfig(config, new Error(!config ? "Config is not a valid value" : "Option `view` is missed")); } return config; } composeConfig(config, extension) { config = this.normalizeConfig(config); extension = this.normalizeConfig(extension); if (config && extension) { return Array.isArray(config) ? config.map((item) => regConfigTransition({ ...item, ...extension }, [item, extension])) : regConfigTransition({ ...config, ...extension }, [config, extension]); } return config || extension; } propsFromConfig(config, data, context, fn = this.get(config?.view)?.options.props) { let props = {}; for (const [key, value] of Object.entries(config)) { if (!configOnlyProps.has(key)) { props[key] = typeof value === "string" && value.startsWith("=") ? this.host.query(value.slice(1), data, context) : value; } } if (typeof fn === "function") { const normProps = fn(data, { props, context }); if (normProps !== null && typeof normProps === "object" && normProps !== props) { propsTransitions.set(normProps, { props, fn }); props = normProps; } } return props; } textWidth(node) { if (node === null) { return 0; } if (node instanceof RenderBox) { let res = 0; for (const child of node.children) { res += this.textWidth(child); } return res; } else { if (node instanceof RenderPlaceholder) { return this.textWidth(node.node); } else { return node.value.length; } } } cleanUpRenderTree(node) { if (node === null) { return null; } if (node instanceof RenderPlaceholder) { return this.cleanUpRenderTree(node.node); } if (node instanceof RenderBox) { const children = node.children.map(this.cleanUpRenderTree, this).filter((node2) => node2 !== null); if (children.length === 0) { return null; } const view = this.viewEls.get(node); const border = node.border; node = new RenderBox(node.type, children); node.border = border; if (view) { this.viewEls.set(node, view); } } else if (node.value === "") { return null; } return node; } serialize(root) { const cleanTreeRoot = this.cleanUpRenderTree(root); const ranges = []; let text = ""; if (cleanTreeRoot !== null) { const { lines, spans } = innerSerialize(cleanTreeRoot, null); let rangeOffset = 0; for (const line of spans) { for (const span of line) { if (span.length > 0) { const end = rangeOffset + span.length; ranges.push({ range: [rangeOffset, end], view: (span.node && this.viewEls.get(span.node)) ?? null }); rangeOffset = end; } } rangeOffset++; } text = lines.join("\n"); } return { text, ranges }; function padLineIfNeeded(lines, lineIdx, minLength, spans, node) { if (lineIdx < lines.length) { if (lines[lineIdx].length < minLength) { const prevValue = lines[lineIdx]; const newValue = prevValue.padEnd(minLength); const lineSpans = spans[lineIdx]; const lastSpan = lineSpans.at(-1); lines[lineIdx] = newValue; if (lastSpan) { lastSpan.length += newValue.length - prevValue.length; } else { spans[lineIdx].push({ length: newValue.length, node }); } } } } function innerSerialize(node, parent) { if (node instanceof RenderText) { const lines2 = node.value.split(/\r\n?|\n/); return { spans: lines2.map((line) => [{ length: line.length, node: parent }]), lines: lines2 }; } const spans = [[]]; const lines = [""]; let currentLineIdx = 0; if (node instanceof RenderBox) { let prevType = "none"; for (const child of node.children) { const type = child instanceof RenderBox ? child.type : "inline"; const { lines: childLines, spans: childSpans } = innerSerialize(child, node); if (childLines.length > 0) { switch (type) { case "block": currentLineIdx = lines.length - 1; if (lines[currentLineIdx] !== "") { spans.push([]); lines.push(""); } else if (currentLineIdx > 0 && lines[currentLineIdx] !== "") { spans.push([]); lines.push(""); } else if (currentLineIdx === 0) { spans.pop(); lines.pop(); } spans.push(...childSpans); lines.push(...childLines); spans.push([]); currentLineIdx = lines.push("") - 1; break; case "line": if (prevType === "block") { } else if (lines[currentLineIdx] === "") { spans.pop(); lines.pop(); } spans.push(...childSpans); lines.push(...childLines); spans.push([]); currentLineIdx = lines.push("") - 1; break; case "inline-block": { if (prevType === "block") { currentLineIdx = lines.push("") - 1; spans.push([]); } else if (prevType === "inline" || prevType === "inline-block") { padLineIfNeeded( lines, currentLineIdx, maxLinesLength(lines, currentLineIdx) + 1, spans, node ); } const pad = lines[currentLineIdx].length; spans[currentLineIdx].push(...childSpans[0]); lines[currentLineIdx] += childLines[0]; if (childLines.length > 1) { const extraLines = childLines.length - (lines.length - currentLineIdx); for (let i = 0; i < extraLines; i++) { spans.push([]); lines.push(""); } for (let i = 1; i < childLines.length; i++) { padLineIfNeeded(lines, currentLineIdx + i, pad, spans, node); spans[currentLineIdx + i].push(...childSpans[i]); lines[currentLineIdx + i] += childLines[i]; } } break; } case "inline": if (prevType === "block") { currentLineIdx = lines.push("") - 1; spans.push([]); } else if (prevType === "inline-block") { padLineIfNeeded( lines, currentLineIdx, maxLinesLength(lines, currentLineIdx) + 1, spans, node ); } spans[currentLineIdx].push(...childSpans[0]); lines[currentLineIdx] += childLines[0]; if (childLines.length > 1) { for (let i = 1; i < childLines.length; i++) { currentLineIdx = lines.push(childLines[i]) - 1; spans[currentLineIdx] = childSpans[i]; } } break; } prevType = type; } } while (prevType !== "inline" && lines[currentLineIdx] === "") { currentLineIdx--; spans.pop(); lines.pop(); } if (node.border) { return node.border.render(lines, spans, node); } } return { spans, lines }; } } async render(container, config, data, context, dataIndex) { if (typeof container === "string") { container = new RenderBox(container); } else if (!container) { container = new RenderBox(); } await render( this, container, this.ensureValidConfig(this.normalizeConfig(config)), data, dataIndex, context ); return container; } async renderString(container, config, data, context, dataIndex) { return this.serialize(await this.render(container, config, data, context, dataIndex)); } listLimit(value, defaultValue) { if (value === false) { return false; } if (!value || isNaN(value)) { return defaultValue; } return Math.max(parseInt(value, 10), 0) || defaultValue; } renderList(container, itemConfig, data, context, opts) { const { offset = 0, moreContainer, beforeItem, afterItem, beforeMore = beforeItem, afterMore = afterItem } = opts; let { limit = false } = opts; if (limit === false) { limit = data.length; } const result = Promise.all( data.slice(offset, offset + limit).map((_, sliceIndex, slice) => { beforeItem && container.appendText(beforeItem); const render2 = this.render(container, itemConfig, data, { ...context, index: offset + sliceIndex, array: data, sliceIndex, slice }, offset + sliceIndex); afterItem && container.appendText(afterItem); return render2; }) ); this.maybeMore( moreContainer || container, null, data.length, offset + limit, beforeMore, afterMore ); return result; } maybeMore(container, beforeEl, count, offset, beforeMore, afterMore) { if (count > offset) { const restCount = count - offset; container.appendText((beforeMore || "") + "(" + restCount + " more\u2026)" + (afterMore || "")); } } getViewConfigTransitionTree(value) { let deps = configTransitions.get(value) || []; if (!Array.isArray(deps)) { deps = [deps]; } return { value, deps: deps.map(this.getViewConfigTransitionTree, this) }; } getViewPropsTransition(value) { const transition = propsTransitions.get(value) || null; return transition && { props: transition.props, fn: transition.fn, query: transition.fn.query || null }; } }