Mustache Template Renderer (#23)

* experimental version of the mustache renderer

* mustache error message / loading cleanup, add refresh button
This commit is contained in:
sawka 2023-09-17 21:00:54 -07:00 committed by GitHub
parent 4953550425
commit e30128d182
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 282 additions and 3 deletions

View File

@ -13,6 +13,7 @@
"mobx": "^6.6.0", "mobx": "^6.6.0",
"mobx-react": "^7.5.0", "mobx-react": "^7.5.0",
"monaco-editor": "^0.41.0", "monaco-editor": "^0.41.0",
"mustache": "^4.2.0",
"node-fetch": "^3.2.10", "node-fetch": "^3.2.10",
"react": "^18.1.0", "react": "^18.1.0",
"react-dom": "^18.1.0", "react-dom": "^18.1.0",

View File

@ -169,8 +169,13 @@ function getMods(input: any) {
} }
function shNavHandler(event: any, url: any) { function shNavHandler(event: any, url: any) {
console.log("navigation canceled", url);
event.preventDefault(); event.preventDefault();
if (url.startsWith("https://") || url.startsWith("http://") || url.startsWith("file://")) {
console.log("open external, shNav", url);
electron.shell.openExternal(url);
} else {
console.log("navigation canceled", url);
}
} }
function shFrameNavHandler(event: any, url: any) { function shFrameNavHandler(event: any, url: any) {
@ -183,6 +188,7 @@ function shFrameNavHandler(event: any, url: any) {
if (event.frame.name == "webview") { if (event.frame.name == "webview") {
// "webview" links always open in new window // "webview" links always open in new window
// this will *not* effect the initial load because srcdoc does not count as an electron navigation // this will *not* effect the initial load because srcdoc does not count as an electron navigation
console.log("open external, frameNav", url);
electron.shell.openExternal(url); electron.shell.openExternal(url);
return; return;
} }
@ -301,13 +307,19 @@ function createMainWindow(clientData) {
}); });
win.webContents.setWindowOpenHandler(({ url, frameName }) => { win.webContents.setWindowOpenHandler(({ url, frameName }) => {
if (url.startsWith("https://docs.getprompt.dev/")) { if (url.startsWith("https://docs.getprompt.dev/")) {
console.log("openExternal docs", url);
electron.shell.openExternal(url); electron.shell.openExternal(url);
} else if (url.startsWith("https://discord.gg/")) { } else if (url.startsWith("https://discord.gg/")) {
console.log("openExternal discord", url);
electron.shell.openExternal(url); electron.shell.openExternal(url);
} else if (url.startsWith("https://extern/?")) { } else if (url.startsWith("https://extern/?")) {
let qmark = url.indexOf("?"); let qmark = url.indexOf("?");
let param = url.substr(qmark + 1); let param = url.substr(qmark + 1);
let newUrl = decodeURIComponent(param); let newUrl = decodeURIComponent(param);
console.log("openExternal extern", newUrl);
electron.shell.openExternal(newUrl);
} else if (url.startsWith("http://") || url.startsWith("https://") || url.startsWith("file://")) {
console.log("openExternal fallback", url);
electron.shell.openExternal(newUrl); electron.shell.openExternal(newUrl);
} }
console.log("window-open denied", url); console.log("window-open denied", url);

View File

@ -2,6 +2,7 @@ import { RendererPluginType } from "./types";
import { SimpleImageRenderer } from "./view/image"; import { SimpleImageRenderer } from "./view/image";
import { SimpleMarkdownRenderer } from "./view/markdown"; import { SimpleMarkdownRenderer } from "./view/markdown";
import { SourceCodeRenderer } from "./view/code"; import { SourceCodeRenderer } from "./view/code";
import { SimpleMustacheRenderer } from "./view/mustache";
import { OpenAIRenderer, OpenAIRendererModel } from "./view/openai"; import { OpenAIRenderer, OpenAIRendererModel } from "./view/openai";
import { isBlank } from "./util"; import { isBlank } from "./util";
import { sprintf } from "sprintf-js"; import { sprintf } from "sprintf-js";
@ -28,6 +29,17 @@ const MarkdownPlugin: RendererPluginType = {
simpleComponent: SimpleMarkdownRenderer, simpleComponent: SimpleMarkdownRenderer,
}; };
const MustachePlugin: RendererPluginType = {
name: "mustache",
rendererType: "simple",
heightType: "pixels",
dataType: "blob",
collapseType: "hide",
globalCss: null,
mimeTypes: ["text/plain"],
simpleComponent: SimpleMustacheRenderer,
};
const CodePlugin: RendererPluginType = { const CodePlugin: RendererPluginType = {
name: "code", name: "code",
rendererType: "simple", rendererType: "simple",
@ -87,6 +99,7 @@ if ((window as any).PluginModel == null) {
PluginModel.registerRendererPlugin(MarkdownPlugin); PluginModel.registerRendererPlugin(MarkdownPlugin);
PluginModel.registerRendererPlugin(CodePlugin); PluginModel.registerRendererPlugin(CodePlugin);
PluginModel.registerRendererPlugin(OpenAIPlugin); PluginModel.registerRendererPlugin(OpenAIPlugin);
PluginModel.registerRendererPlugin(MustachePlugin);
(window as any).PluginModel = PluginModel; (window as any).PluginModel = PluginModel;
} }

View File

@ -254,7 +254,7 @@ input[type="checkbox"] {
padding-top: 10px; padding-top: 10px;
padding-bottom: 15px; padding-bottom: 15px;
} }
.monaco-editor .monaco-editor-background { .monaco-editor .monaco-editor-background {
background-color: rgba(255, 255, 255, 0.075) !important; background-color: rgba(255, 255, 255, 0.075) !important;
} }
@ -331,7 +331,26 @@ input[type="checkbox"] {
font-size: 12px; font-size: 12px;
} }
.renderer-container .markdown { .renderer-container.mustache-renderer {
color: @term-white;
.cmd-hints {
display: inline-block !important;
position: relative;
margin-right: 26px;
}
.hint-item {
border-radius: 4px 4px 0 0;
padding: 3px 9px 2px 8px;
line-height: 15px;
text-align: center;
}
.refresh-button {
color: rgb(52, 52, 52);
background-color: @term-white;
}
}
.renderer-container .content {
padding: 5px; padding: 5px;
line-height: 1.5; line-height: 1.5;
width: fit-content; width: fit-content;
@ -350,6 +369,15 @@ input[type="checkbox"] {
margin: 2px 10px 6px 10px; margin: 2px 10px 6px 10px;
padding: 4px 4px 4px 6px; padding: 4px 4px 4px 6px;
} }
h1,
h2,
h3,
h4,
h5,
h6 {
color: @term-white;
}
} }
.openai-renderer { .openai-renderer {

View File

@ -7,6 +7,7 @@ import { Main } from "./main";
import { GlobalModel } from "./model"; import { GlobalModel } from "./model";
import { v4 as uuidv4 } from "uuid"; import { v4 as uuidv4 } from "uuid";
import { loadFonts } from "./util"; import { loadFonts } from "./util";
import * as DOMPurify from "dompurify";
// @ts-ignore // @ts-ignore
let VERSION = __PROMPT_VERSION__; let VERSION = __PROMPT_VERSION__;
@ -31,5 +32,6 @@ document.addEventListener("DOMContentLoaded", () => {
(window as any).mobx = mobx; (window as any).mobx = mobx;
(window as any).sprintf = sprintf; (window as any).sprintf = sprintf;
(window as any).DOMPurify = DOMPurify;
console.log("PROMPT", VERSION, BUILD); console.log("PROMPT", VERSION, BUILD);

218
src/view/mustache.tsx Normal file
View File

@ -0,0 +1,218 @@
import * as React from "react";
import * as mobx from "mobx";
import * as mobxReact from "mobx-react";
import cn from "classnames";
import { boundMethod } from "autobind-decorator";
import { If, For, When, Otherwise, Choose } from "tsx-control-statements/components";
import * as T from "../types";
import { sprintf } from "sprintf-js";
import { isBlank } from "../util";
import mustache from "mustache";
import * as DOMPurify from "dompurify";
import { GlobalModel } from "../model";
type OV<V> = mobx.IObservableValue<V>;
const MaxMustacheSize = 200000;
@mobxReact.observer
class SimpleMustacheRenderer extends React.Component<
{ data: Blob; context: T.RendererContext; opts: T.RendererOpts; savedHeight: number; lineState: T.LineStateType },
{}
> {
templateLoading: OV<boolean> = mobx.observable.box(true, { name: "templateLoading" });
templateLoadError: OV<string> = mobx.observable.box(null, { name: "templateLoadError" });
dataLoading: OV<boolean> = mobx.observable.box(true, { name: "dataLoading" });
dataLoadError: OV<string> = mobx.observable.box(null, { name: "dataLoadError" });
mustacheTemplateText: OV<string> = mobx.observable.box(null, { name: "mustacheTemplateText" });
parsedData: OV<any> = mobx.observable.box(null, { name: "parsedData" });
componentDidMount() {
this.reloadTemplate();
this.reloadData();
}
reloadTemplate() {
if (isBlank(this.props.lineState.template)) {
mobx.action(() => {
this.templateLoading.set(false);
this.templateLoadError.set(`no 'template' specified`);
})();
return;
}
mobx.action(() => {
this.templateLoading.set(true);
this.templateLoadError.set(null);
})();
let context = this.props.context;
let lineState = this.props.lineState;
let quotedTemplateName = JSON.stringify(lineState.template);
let rtnp = GlobalModel.readRemoteFile(context.screenId, context.lineId, lineState.template);
rtnp.then((file) => {
if (file.notFound) {
this.trySetTemplateLoadError(`mustache template ${quotedTemplateName} not found`);
return null;
}
return file.text();
})
.then((text) => {
if (isBlank(text)) {
this.trySetTemplateLoadError(`blank mustache template ${quotedTemplateName}`);
return;
}
mobx.action(() => {
this.mustacheTemplateText.set(text);
this.templateLoading.set(false);
})();
return;
})
.catch((e) => {
this.trySetTemplateLoadError(`loading mustache template ${quotedTemplateName}: ${e}`);
});
}
reloadData() {
// load json content
let dataBlob = this.props.data;
if (dataBlob == null || dataBlob.notFound) {
mobx.action(() => {
this.dataLoading.set(false);
this.dataLoadError.set(
`file {dataBlob && dataBlob.name ? JSON.stringify(dataBlob.name) : ""} not found`
);
})();
return;
}
mobx.action(() => {
this.dataLoading.set(true);
this.dataLoadError.set(null);
})();
let rtnp = dataBlob.text();
let quotedDataName = dataBlob.name || '"terminal output"';
rtnp.then((text) => {
mobx.action(() => {
try {
this.parsedData.set(JSON.parse(text));
this.dataLoading.set(false);
} catch (e) {
this.trySetDataLoadError(`parsing json data from ${quotedDataName}: ${e}`);
}
})();
}).catch((e) => {
this.trySetDataLoadError(`loading json data ${quotedDataName}: ${e}`);
});
}
trySetTemplateLoadError(msg: string) {
if (this.templateLoadError.get() != null) {
return;
}
mobx.action(() => {
this.templateLoadError.set(msg);
})();
}
trySetDataLoadError(msg: string) {
if (this.dataLoadError.get() != null) {
return;
}
mobx.action(() => {
this.dataLoadError.set(msg);
})();
}
@boundMethod
doRefresh() {
this.reloadTemplate();
}
renderCmdHints() {
return (
<div style={{ position: "absolute", bottom: "-3px", right: 0 }}>
<div className="cmd-hints" style={{ minWidth: "6rem", maxWidth: "6rem", marginLeft: "-18px" }}>
<div
onClick={this.doRefresh}
className={`hint-item refresh-button`}
title="reload template and re-render content"
>
refresh
</div>
</div>
</div>
);
}
render() {
let errorMessage = this.dataLoadError.get() ?? this.templateLoadError.get();
if (errorMessage != null) {
return (
<div
className="renderer-container mustache-renderer"
style={{ fontSize: this.props.opts.termFontSize }}
>
<div className="load-error-text">ERROR: {errorMessage}</div>
{this.renderCmdHints()}
</div>
);
}
if (this.templateLoading.get() || this.dataLoading.get()) {
return (
<div
className="renderer-container mustache-renderer"
style={{ fontSize: this.props.opts.termFontSize, height: this.props.savedHeight }}
>
<div className="renderer-loading">
loading content <i className="fa fa-ellipsis fa-fade" />
</div>
{this.renderCmdHints()}
</div>
);
}
let opts = this.props.opts;
let maxWidth = opts.maxSize.width;
let minWidth = opts.maxSize.width;
if (minWidth > 1000) {
minWidth = 1000;
}
let templateText = this.mustacheTemplateText.get();
let templateData = this.parsedData.get() || {};
let renderedText = null;
try {
renderedText = mustache.render(templateText, templateData);
renderedText = DOMPurify.sanitize(renderedText);
} catch (e) {
return (
<div
className="renderer-container mustache-renderer"
style={{ fontSize: this.props.opts.termFontSize }}
>
<div className="load-error-text">ERROR running template: {e.message}</div>
{this.renderCmdHints()}
</div>
);
}
// TODO non-term content font-size (default to 16)
return (
<div className="renderer-container mustache-renderer" style={{ fontSize: 16 }}>
<div
className="scroller"
style={{
maxHeight: opts.maxSize.height,
minWidth: minWidth,
width: "min-content",
maxWidth: maxWidth,
}}
>
<div
className="mustache content"
style={{ maxHeight: opts.maxSize.height }}
dangerouslySetInnerHTML={{ __html: renderedText }}
/>
</div>
{this.renderCmdHints()}
</div>
);
}
}
export { SimpleMustacheRenderer };

View File

@ -5628,6 +5628,11 @@ multicast-dns@^7.2.5:
dns-packet "^5.2.2" dns-packet "^5.2.2"
thunky "^1.0.2" thunky "^1.0.2"
mustache@^4.2.0:
version "4.2.0"
resolved "https://registry.yarnpkg.com/mustache/-/mustache-4.2.0.tgz#e5892324d60a12ec9c2a73359edca52972bf6f64"
integrity sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ==
nanoid@^3.3.6: nanoid@^3.3.6:
version "3.3.6" version "3.3.6"
resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.6.tgz#443380c856d6e9f9824267d960b4236ad583ea4c" resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.6.tgz#443380c856d6e9f9824267d960b4236ad583ea4c"