UNPKG

pomljs

Version:

Prompt Orchestration Markup Language

724 lines (721 loc) 25.8 kB
import * as React from 'react'; import { distance } from 'closest-match'; import { deepMerge } from './util/index.js'; import componentDocs from './assets/componentDocs.json.js'; import path__default from 'path'; import flattenChildren from 'react-keyed-flatten-children'; /** * The very basic logics that drive "every" component in the system. * The "every" is the criteria whether the logic should serve as a base. * For example, the stylesheet is considered as a base, as it's supported in every component, * but markup presentation is not. */ const ValidSpeakers = ['system', 'human', 'ai', 'tool']; function richContentFromSourceMap(contents) { const parts = []; const append = (txt) => { if (parts.length > 0 && typeof parts[parts.length - 1] === 'string') { parts[parts.length - 1] = parts[parts.length - 1] + txt; } else if (txt.length > 0) { parts.push(txt); } }; for (const seg of contents) { const c = seg.content; if (typeof c === 'string') { append(c); } else if (Array.isArray(c)) { for (const item of c) { if (typeof item === 'string') { append(item); } else { parts.push(item); } } } else { parts.push(c); } } if (parts.length === 1) { return typeof parts[0] === 'string' ? parts[0] : [parts[0]]; } return parts; } /** * Create an element that will be visible in the IR. * Helper function for logging and debugging purposes. */ const irElement = (type, props, ...children) => { if (props.speaker && !ValidSpeakers.includes(props.speaker)) { ErrorCollection.add(ReadError.fromProps(`"${props.speaker}" is not a valid speaker.`, props)); props.speaker = undefined; } const propsWithoutUndefined = Object.fromEntries(Object.entries(props) .filter(([_, v]) => v !== undefined) .map(([k, v]) => { const hyphenCaseKey = k.replace(/[A-Z]/g, m => '-' + m.toLowerCase()); if (typeof v === 'boolean') { return [hyphenCaseKey, v.toString()]; } else if (typeof v === 'number') { return [hyphenCaseKey, v.toString()]; } else if (typeof v === 'object') { return [hyphenCaseKey, JSON.stringify(v)]; } else { return [hyphenCaseKey, v]; } })); const trimmedChildren = trimChildrenWhiteSpace(children, props); return React.createElement(type, propsWithoutUndefined, ...trimmedChildren); }; function trimChildrenWhiteSpace(children, props) { // This is exposed for providers. // The children directly under a context provider also needs to be trimmed, // otherwise they do not have a chance to be trimmed. let flattenedChildren = flattenChildren(children); // Merge consecutive strings. if (props.whiteSpace !== 'pre') { const mergedChildren = []; let currentString = ''; for (const child of flattenedChildren) { if (typeof child === 'string') { currentString += child; } else { if (currentString) { mergedChildren.push(currentString); currentString = ''; } mergedChildren.push(child); } } if (currentString) { mergedChildren.push(currentString); } flattenedChildren = mergedChildren; } const trimmedChildren = flattenedChildren .map((child, index) => { if (typeof child === 'string') { if (props.whiteSpace === 'pre') { return child; } else if (props.whiteSpace === 'filter' || props.whiteSpace === undefined) { return trimText(child, index === 0, index === flattenedChildren.length - 1); } else if (props.whiteSpace === 'trim') { let trimmed = child; if (index === 0) { trimmed = trimmed.trimStart(); } if (index === flattenedChildren.length - 1) { trimmed = trimmed.trimEnd(); } return trimmed; } else { ErrorCollection.add(ReadError.fromProps(`"${props.whiteSpace}" is not a valid whiteSpace option.`, props)); return child; } } else { return child; } }) .filter(c => c !== ''); return trimmedChildren; } /** * Trim the element tree following the CSS rules * https://developer.mozilla.org/en-US/docs/Web/API/Document_Object_Model/Whitespace */ const trimText = (text, isFirst, isLast) => { // 1. all spaces and tabs immediately before and after a line break are ignored text = text.replace(/[\t\n\r ]*\n[\t\n\r ]*/g, '\n'); // 2. all tab characters and line breaks are handled as space characters text = text.replace(/[\t\n\r]/g, ' '); // 3. multiple space characters are handled as one space character text = text.replace(/ +/g, ' '); // 4. sequences of spaces at the beginning and end of an element are removed if (isFirst) { text = text.replace(/^ +/, ''); } if (isLast) { text = text.replace(/ +$/, ''); } return text; }; class PomlError extends Error { severity = 'error'; constructor(message, options) { super(message, options); this.name = 'PomlError'; if (options?.severity) { this.severity = options.severity; } } } class SystemError extends PomlError { constructor(message, options) { super(message, options); this.name = 'SystemError'; } } class ReadError extends PomlError { startIndex; endIndex; sourcePath; constructor(message, startIndex, endIndex, sourcePath, options) { super(message, options); this.startIndex = startIndex; this.endIndex = endIndex; this.sourcePath = sourcePath; this.name = 'ReadError'; } static fromProps(message, props, options) { return new ReadError(message, props.originalStartIndex, props.originalEndIndex, props.sourcePath, options); } } class WriteError extends PomlError { startIndex; endIndex; sourcePath; irStartIndex; irEndIndex; relatedIr; constructor(message, startIndex, endIndex, sourcePath, irStartIndex, irEndIndex, relatedIr, options) { super(message, options); this.startIndex = startIndex; this.endIndex = endIndex; this.sourcePath = sourcePath; this.irStartIndex = irStartIndex; this.irEndIndex = irEndIndex; this.relatedIr = relatedIr; this.name = 'WriteError'; } } /** * A can to hold all the errors. */ class ErrorCollection { errors = []; static _instance; constructor() { } static get instance() { if (!this._instance) { this._instance = new ErrorCollection(); } return this._instance; } static add(error) { this.instance.errors.push(error); } static first() { return this.instance.errors[0]; } static last() { return this.instance.errors[this.instance.errors.length - 1]; } static list() { return this.instance.errors; } static empty() { return this.instance.errors.length === 0; } static clear() { this.instance.errors = []; } } function calculateSize(value, visited = new Set()) { if (value === null || value === undefined) { return 0; } if (visited.has(value)) { return 0; } if (Buffer.isBuffer(value)) { return value.length; } const t = typeof value; if (t === 'string') { return Buffer.byteLength(value); } if (t === 'number' || t === 'boolean' || t === 'bigint') { return 8; } if (Array.isArray(value)) { visited.add(value); let size = 0; for (const item of value) { try { size += calculateSize(item, visited); } catch { // ignore } } visited.delete(value); return size; } if (t === 'object') { visited.add(value); let size = 0; for (const key in value) { try { size += calculateSize(value[key], visited); } catch { // ignore } } visited.delete(value); return size; } return 0; } class BufferCollection { buffers = new Map(); totalSize = 0; limit = 10 * 1024 * 1024; // 10MB default static _instance; constructor() { } static get instance() { if (!this._instance) { this._instance = new BufferCollection(); } return this._instance; } evict() { while (this.totalSize > this.limit && this.buffers.size > 0) { const oldestKey = this.buffers.keys().next().value; const entry = this.buffers.get(oldestKey); if (entry) { this.totalSize -= entry.size; } this.buffers.delete(oldestKey); } } static get(key) { const entry = this.instance.buffers.get(key); return entry ? entry.value : undefined; } static set(key, value) { const inst = this.instance; const prev = inst.buffers.get(key); if (prev) { inst.totalSize -= prev.size; } const entrySize = calculateSize(value); if (entrySize > inst.limit) { return; } inst.buffers.set(key, { value, size: entrySize }); inst.totalSize += entrySize; inst.evict(); } static clear() { this.instance.buffers.clear(); this.instance.totalSize = 0; } } const useWithCatch = (promise, props) => { const catchedPromise = promise.catch((err) => { if (err instanceof PomlError) { ErrorCollection.add(err); } else { ErrorCollection.add(ReadError.fromProps(err && err.message ? err.message : 'Unknown error happened during asynchroneous process of rendering.', props, { cause: err })); } }); return React.use(catchedPromise); }; const StyleSheetContext = React.createContext({}); const StyleSheetProvider = ({ stylesheet, children }) => { const currentStylesheet = React.useContext(StyleSheetContext); // Deep merge stylesheet stylesheet = deepMerge(currentStylesheet, stylesheet); return React.createElement(StyleSheetContext.Provider, { value: stylesheet }, children); }; const useStyleSheet = () => React.useContext(StyleSheetContext); const computeStyles = (currentProps, component, _stylesheet) => { const stylesheet = _stylesheet !== undefined ? _stylesheet : useStyleSheet(); // priority, order, props const matches = []; Object.entries(stylesheet).forEach(([match, props], index) => { if (match === '*') { matches.push([0, index, props]); } else { const matchResult = match.split(/\s+/g).map(indiv => { // FIXME: this is different from css rule if (indiv.startsWith('.')) { const currentClassName = currentProps?.className; const currentClasses = currentClassName ? currentClassName.split(/\s+/g) : []; return currentClasses.includes(indiv.slice(1)) ? 2 : 0; } else { return component.getAliases().includes(indiv.toLowerCase()) ? 1 : 0; } }); if (matchResult.every(r => r > 0)) { matches.push([matchResult.reduce((a, b) => a + b, 0), index, props]); } } }); matches.sort((a, b) => (a[0] == b[0] ? a[1] - b[1] : a[0] - b[0])); const { className, ...restProps } = currentProps; matches.push([999, -1, restProps]); let finalProps = {}; matches.forEach(([, , props]) => { finalProps = deepMerge(finalProps, props); }); return finalProps; }; // Source provider provides a path to the source file. // It's used to find related files specified in the source file. // It's also used to locate the source file for debugging purposes. const SourceContext = React.createContext(''); const SourceProvider = ({ source, children }) => { return React.createElement(SourceContext.Provider, { value: source }, children); }; const expandRelative = (src) => { if (path__default.isAbsolute(src)) { return src; } const pomlSource = React.useContext(SourceContext); if (!pomlSource) { return src; } return path__default.resolve(path__default.dirname(pomlSource), src); }; class PomlComponent { officialName; componentFunc; options; constructor(officialName, componentFunc, options) { this.officialName = officialName; this.componentFunc = componentFunc; this.options = options; } get name() { return this.officialName; } getAliases(lower = true) { if (lower) { return this.options.aliases.map(alias => alias.toLowerCase()); } else { return this.options.aliases; } } warnsIfProvided(props) { if (!props) { return; } this.options.unwantedProps.forEach(key => { if (props[key] !== undefined) { ErrorCollection.add(ReadError.fromProps(`"${key}" is not supported (but provided) in ${this.officialName}.`, props, { severity: 'warning' })); } }); } throwIfMissing(props) { this.options.requiredProps.forEach(key => { if (!props || props[key] === undefined) { throw ReadError.fromProps(`"${key}" is required but not provided for ${this.officialName}, available props are ${props ? Object.keys(props) : []}.`, props); } }); } isPublic() { return this.spec() !== undefined; } static fromSpec(spec) { const found = findComponentByAliasOrUndefined(spec.name ?? ''); if (found !== undefined) { return found; } throw new SystemError(`Component ${spec.name} not found.`); } spec() { return componentDocs.find(document => document.name === this.name); } parameters() { const spec = this.spec(); if (!spec) { return []; } const bases = this.mro(); const parameters = [...spec.params]; for (const base of bases) { const baseSpec = base.spec(); if (baseSpec) { parameters.push(...baseSpec.params.filter(p => !parameters.map(p => p.name).includes(p.name))); } } return parameters; } mro() { const spec = this.spec(); if (!spec) { return []; } const toSearch = [...spec.baseComponents]; const result = []; let searchPointer = 0; while (searchPointer < toSearch.length) { const component = findComponentByAliasOrUndefined(toSearch[searchPointer]); if (component !== undefined) { result.push(component); const componentSpec = component.spec(); if (componentSpec) { for (const base of componentSpec.baseComponents) { if (!toSearch.includes(base) && !result.map(c => c.name).includes(base)) { toSearch.push(base); } } } } searchPointer++; } return result; } style(props, stylesheet) { return computeStyles(props, this, stylesheet); } preprocessProps(props) { const params = this.parameters(); return Object.entries(props).reduce((acc, [key, value]) => { const param = params.find(param => param.name.toLowerCase() === key.toLowerCase()); if (!param) { // Keep it. acc[key] = value; return acc; } const formalKey = param.name; if (value === undefined) { // TODO: check required parameters acc[key] = value; return acc; } if (param.type === 'string') { if (typeof value !== 'string' && value !== undefined) { value = value.toString(); } } else if (param.type === 'number') { if (typeof value !== 'number' && value !== undefined) { value = parseFloat(value); } } else if (param.type === 'boolean') { if (typeof value !== 'boolean') { const isTrue = ['1', 'true'].includes(value.toString().toLowerCase()); const isFalse = ['0', 'false'].includes(value.toString().toLowerCase()); if (!isTrue && !isFalse) { ErrorCollection.add(ReadError.fromProps(`"${key}" should be a boolean`, props)); value = undefined; } value = isTrue; } } else if (param.type === 'object' || param.type === 'object|string') { if (typeof value === 'string') { try { value = JSON.parse(value); } catch (e) { if (param.fallbackType !== 'string') { ErrorCollection.add(ReadError.fromProps(`Fail to parse \"${key}\" with JSON parser`, props)); } } } } else if (param.type === 'RegExp' || param.type === 'RegExp|string') { if (typeof value === 'string') { if (value.startsWith('/')) { // Extract flags if present const lastSlashIndex = value.lastIndexOf('/'); if (lastSlashIndex > 0) { const pattern = value.substring(1, lastSlashIndex); const flags = value.substring(lastSlashIndex + 1); // Only create RegExp with flags if flags exist and are valid if (flags && /^[gimsuy]*$/.test(flags)) { value = new RegExp(pattern, flags); } else if (lastSlashIndex === value.length - 1) { // Format is /pattern/ with no flags value = new RegExp(pattern); } } } else { // Default behavior for strings not in /pattern/ format value = new RegExp(value); } } } else ; if (param.choices.length > 0) { if (!param.choices.includes(value)) { ErrorCollection.add(ReadError.fromProps(`"${key}" should be one of ${param.choices.join(', ')}, not ${value}`, props)); } } acc[formalKey] = value; return acc; }, {}); } render(props) { this.warnsIfProvided(props); try { // If one of the following steps has error, abort the process. this.throwIfMissing(props); if (this.options.applyStyleSheet) { props = this.style(props); } props = this.preprocessProps(props); if (this.options.asynchorous) { const msg = 'This prompt is asynchorous and still loading. Users should not see this message. ' + 'If you see this message, please report it to the developer.'; return (React.createElement(React.Suspense, { fallback: React.createElement("div", null, msg) }, this.componentFunc(props))); } else { return this.componentFunc(props); } } catch (e) { if (e && typeof e.message === 'string' && e.message.startsWith('Suspense Exception:')) { throw e; } if (e instanceof PomlError) { ErrorCollection.add(e); } else { ErrorCollection.add(ReadError.fromProps(`Error in component render of ${this.officialName}: ${e}`, props, { cause: e })); } return null; } } } class ComponentRegistry { static _instance; components = []; constructor() { } static get instance() { if (!this._instance) { this._instance = new ComponentRegistry(); } return this._instance; } registerComponent(officialName, component, options) { if (!options.aliases.includes(officialName)) { options.aliases = [officialName, ...options.aliases]; } options.aliases.forEach(alias => { const aliasExisting = this.components.filter(c => c.getAliases().includes(alias.toLowerCase())); if (aliasExisting.length > 0) { throw new SystemError(`Alias "${alias}" is already used by ${aliasExisting[0]}.`); } }); const registered = new PomlComponent(officialName, component, options); this.components.push(registered); return registered; } unregisterComponent(name) { const component = this.getComponent(name); this.components = this.components.filter(c => c !== component); } listComponents() { return [...this.components]; } getComponent(name, returnReasonIfNotFound = false, disabled = undefined) { if (returnReasonIfNotFound instanceof Set) { disabled = returnReasonIfNotFound; returnReasonIfNotFound = false; } const hyphenToCamelCase = (s) => { return s.toLowerCase().replace(/-([a-z])/g, g => g[1].toUpperCase()); }; const nameVariants = [name.toLowerCase(), hyphenToCamelCase(name).toLowerCase()]; for (const variant of nameVariants) { for (const component of this.components) { const aliases = component.getAliases(); if (!aliases.includes(variant)) { continue; } if (disabled?.has(variant)) { continue; } return component; } } if (!returnReasonIfNotFound) { return undefined; } const availableAliases = this.components .flatMap(c => c.getAliases()) .filter(a => !disabled?.has(a)); const distances = availableAliases.map(alias => { return { alias: alias, dist: distance(alias.toLowerCase(), name.toLowerCase()) }; }); distances.sort((a, b) => a.dist - b.dist); const doYouMean = distances.filter((d, index) => index < 1 || d.dist <= 2); return `Component ${name} not found. Do you mean: ${doYouMean.map(d => d.alias).join(', ')}?`; } } function component(name, options) { return (target) => { const registered = ComponentRegistry.instance.registerComponent(name, target, options ? Array.isArray(options) ? { aliases: options, requiredProps: [], unwantedProps: [], applyStyleSheet: true, asynchorous: false } : { aliases: options.aliases ?? [], requiredProps: options.requiredProps ?? [], unwantedProps: options.unwantedProps ?? [], applyStyleSheet: options.applyStyleSheet ?? true, asynchorous: options.asynchorous ?? false } : { aliases: [], requiredProps: [], unwantedProps: [], applyStyleSheet: true, asynchorous: false }); return registered.render.bind(registered); }; } /** * Find a component by its alias. If not found, return a string that suggests the closest match. * @param alias Alias or official name. */ function findComponentByAlias(alias, disabled) { return ComponentRegistry.instance.getComponent(alias, true, disabled); } function findComponentByAliasOrUndefined(alias, disabled) { return ComponentRegistry.instance.getComponent(alias, disabled); } function listComponents() { return ComponentRegistry.instance.listComponents(); } export { BufferCollection, ErrorCollection, PomlComponent, ReadError, SourceProvider, StyleSheetProvider, SystemError, ValidSpeakers, WriteError, component, expandRelative, findComponentByAlias, findComponentByAliasOrUndefined, irElement, listComponents, richContentFromSourceMap, trimChildrenWhiteSpace, useWithCatch }; //# sourceMappingURL=base.js.map