Complete redo of content indexing and querying (#517)
Complete redo of data store Introduces live queries and live templates
This commit is contained in:
@@ -0,0 +1,102 @@
|
||||
async function init() {
|
||||
// Make edit button send the "blur" API call so that the MD code is visible
|
||||
document.getElementById("edit-button").addEventListener("click", () => {
|
||||
api({ type: "blur" });
|
||||
});
|
||||
document.getElementById("reload-button").addEventListener("click", () => {
|
||||
api({ type: "reload" });
|
||||
});
|
||||
|
||||
document.querySelectorAll("a[data-ref]").forEach((el) => {
|
||||
el.addEventListener("click", (e) => {
|
||||
e.preventDefault();
|
||||
syscall("editor.navigate", el.dataset.ref);
|
||||
});
|
||||
});
|
||||
|
||||
// Find all fenced code blocks and replace them with iframes (if a code widget is defined for them)
|
||||
const allWidgets = document.querySelectorAll("pre[data-lang]");
|
||||
for (const widget of allWidgets) {
|
||||
const lang = widget.getAttribute("data-lang");
|
||||
const body = widget.innerText;
|
||||
|
||||
try {
|
||||
const result = await syscall("widget.render", lang, body);
|
||||
const iframe = document.createElement("iframe");
|
||||
iframe.srcdoc = panelHtml; // set as a global
|
||||
iframe.onload = () => {
|
||||
iframe.contentWindow.postMessage({
|
||||
type: "html",
|
||||
theme: document.getElementsByTagName("html")[0].getAttribute(
|
||||
"data-theme",
|
||||
),
|
||||
...result,
|
||||
}, "*");
|
||||
};
|
||||
widget.parentNode.replaceChild(iframe, widget);
|
||||
|
||||
globalThis.addEventListener("message", (e) => {
|
||||
if (e.source !== iframe.contentWindow) {
|
||||
return;
|
||||
}
|
||||
const messageData = e.data;
|
||||
switch (messageData.type) {
|
||||
case "setHeight":
|
||||
iframe.style.height = messageData.height + "px";
|
||||
// Propagate height setting to parent
|
||||
updateHeight();
|
||||
break;
|
||||
case "syscall": {
|
||||
// Intercept syscall messages and send them to the parent
|
||||
const { id, name, args } = messageData;
|
||||
syscall(name, ...args).then((result) => {
|
||||
iframe.contentWindow.postMessage(
|
||||
{ id, type: "syscall-response", result },
|
||||
"*",
|
||||
);
|
||||
}).catch((error) => {
|
||||
iframe.contentWindow.postMessage({
|
||||
id,
|
||||
type: "syscall-response",
|
||||
error,
|
||||
}, "*");
|
||||
});
|
||||
break;
|
||||
}
|
||||
default:
|
||||
// Bubble up any other messages to parent iframe
|
||||
window.parent.postMessage(messageData, "*");
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
if (e.message.includes("not found")) {
|
||||
// Not a code widget, ignore
|
||||
} else {
|
||||
console.error("Error rendering widget", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Find all task toggles and propagate their state
|
||||
document.querySelectorAll("span[data-external-task-ref]").forEach((el) => {
|
||||
const taskRef = el.dataset.externalTaskRef;
|
||||
el.querySelector("input[type=checkbox]").addEventListener("change", (e) => {
|
||||
const oldState = e.target.dataset.state;
|
||||
const newState = oldState === " " ? "x" : " ";
|
||||
// Update state in DOM as well for future toggles
|
||||
e.target.dataset.state = newState;
|
||||
console.log("Toggling task", taskRef);
|
||||
syscall(
|
||||
"system.invokeFunction",
|
||||
"tasks.updateTaskState",
|
||||
taskRef,
|
||||
oldState,
|
||||
newState,
|
||||
).catch(
|
||||
console.error,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
init().catch(console.error);
|
||||
@@ -0,0 +1,45 @@
|
||||
body {
|
||||
font-family: var(--editor-font);
|
||||
background-color: var(--root-background-color);
|
||||
color: var(--root-color);
|
||||
overflow: scroll;
|
||||
}
|
||||
|
||||
ul li p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
body:hover #button-bar,
|
||||
body:active #button-bar {
|
||||
display: block;
|
||||
}
|
||||
|
||||
#button-bar {
|
||||
position: absolute;
|
||||
right: 12px;
|
||||
top: 3px;
|
||||
display: none;
|
||||
background: rgb(255 255 255 / 0.9);
|
||||
padding: 0 3px;
|
||||
}
|
||||
|
||||
#button-bar button {
|
||||
border: none;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
color: var(--root-color);
|
||||
}
|
||||
|
||||
#edit-button {
|
||||
margin-left: -10px;
|
||||
}
|
||||
|
||||
li code {
|
||||
font-size: 80%;
|
||||
color: #a5a4a4;
|
||||
}
|
||||
|
||||
iframe {
|
||||
border: none;
|
||||
width: 100%;
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { CompleteEvent } from "$sb/app_event.ts";
|
||||
import { events } from "$sb/syscalls.ts";
|
||||
import {
|
||||
AttributeCompleteEvent,
|
||||
AttributeCompletion,
|
||||
} from "../index/attributes.ts";
|
||||
|
||||
export async function queryComplete(completeEvent: CompleteEvent) {
|
||||
const fencedParent = completeEvent.parentNodes.find((node) =>
|
||||
node === "FencedCode:query"
|
||||
);
|
||||
if (!fencedParent) {
|
||||
return null;
|
||||
}
|
||||
let querySourceMatch = /^\s*([\w\-_]*)$/.exec(
|
||||
completeEvent.linePrefix,
|
||||
);
|
||||
if (querySourceMatch) {
|
||||
const allEvents = await events.listEvents();
|
||||
|
||||
const completionOptions = allEvents
|
||||
.filter((eventName) =>
|
||||
eventName.startsWith("query:") && !eventName.includes("*")
|
||||
)
|
||||
.map((source) => ({
|
||||
label: source.substring("query:".length),
|
||||
}));
|
||||
|
||||
const allObjectTypes: string[] = (await events.dispatchEvent("query_", {}))
|
||||
.flat();
|
||||
|
||||
for (const type of allObjectTypes) {
|
||||
completionOptions.push({
|
||||
label: type,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
from: completeEvent.pos - querySourceMatch[1].length,
|
||||
options: completionOptions,
|
||||
};
|
||||
}
|
||||
|
||||
querySourceMatch = /^\s*([\w\-_]*)/.exec(
|
||||
completeEvent.linePrefix,
|
||||
);
|
||||
const whereMatch =
|
||||
/(where|order\s+by|and|or|select(\s+[\w\s,]+)?)\s+([\w\-_]*)$/
|
||||
.exec(
|
||||
completeEvent.linePrefix,
|
||||
);
|
||||
if (querySourceMatch && whereMatch) {
|
||||
const type = querySourceMatch[1];
|
||||
const attributePrefix = whereMatch[3];
|
||||
const completions = (await events.dispatchEvent(
|
||||
`attribute:complete:${type}`,
|
||||
{
|
||||
source: type,
|
||||
prefix: attributePrefix,
|
||||
} as AttributeCompleteEvent,
|
||||
)).flat() as AttributeCompletion[];
|
||||
return {
|
||||
from: completeEvent.pos - attributePrefix.length,
|
||||
options: attributeCompletionsToCMCompletion(completions),
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function attributeCompletionsToCMCompletion(
|
||||
completions: AttributeCompletion[],
|
||||
) {
|
||||
return completions.map(
|
||||
(completion) => ({
|
||||
label: completion.name,
|
||||
detail: `${completion.attributeType} (${completion.source})`,
|
||||
type: "attribute",
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
name: query
|
||||
assets:
|
||||
- "assets/*"
|
||||
functions:
|
||||
queryWidget:
|
||||
path: query.ts:widget
|
||||
codeWidget: query
|
||||
|
||||
templateWidget:
|
||||
path: template.ts:widget
|
||||
codeWidget: template
|
||||
|
||||
queryComplete:
|
||||
path: complete.ts:queryComplete
|
||||
events:
|
||||
- editor:complete
|
||||
|
||||
# Slash commands
|
||||
insertQuery:
|
||||
redirect: template.insertTemplateText
|
||||
slashCommand:
|
||||
name: query
|
||||
description: Insert a query
|
||||
value: |
|
||||
```query
|
||||
|^|
|
||||
```
|
||||
insertInclude:
|
||||
redirect: template.insertTemplateText
|
||||
slashCommand:
|
||||
name: include
|
||||
description: Include another page
|
||||
value: |
|
||||
<!-- #include [[|^|]] -->
|
||||
|
||||
<!-- /include -->
|
||||
insertUseTemplate:
|
||||
redirect: template.insertTemplateText
|
||||
slashCommand:
|
||||
name: template
|
||||
description: Use a template
|
||||
value: |
|
||||
```template
|
||||
page: "[[|^|]]"
|
||||
```
|
||||
@@ -0,0 +1,79 @@
|
||||
import type { WidgetContent } from "$sb/app_event.ts";
|
||||
import { editor, events, language, markdown, space } from "$sb/syscalls.ts";
|
||||
import { parseTreeToAST } from "$sb/lib/tree.ts";
|
||||
import { astToKvQuery } from "$sb/lib/parse-query.ts";
|
||||
import { jsonToMDTable, renderTemplate } from "../directive/util.ts";
|
||||
import { renderMarkdownToHtml } from "../markdown/markdown_render.ts";
|
||||
import { replaceTemplateVars } from "../template/template.ts";
|
||||
import { prepareJS, wrapHTML } from "./util.ts";
|
||||
|
||||
export async function widget(bodyText: string): Promise<WidgetContent> {
|
||||
const pageMeta = await space.getPageMeta(await editor.getCurrentPage());
|
||||
|
||||
try {
|
||||
const queryAST = parseTreeToAST(
|
||||
await language.parseLanguage("query", bodyText),
|
||||
);
|
||||
const parsedQuery = astToKvQuery(
|
||||
JSON.parse(
|
||||
await replaceTemplateVars(JSON.stringify(queryAST[1]), pageMeta),
|
||||
),
|
||||
);
|
||||
|
||||
// console.log("actual query", parsedQuery);
|
||||
const eventName = `query:${parsedQuery.querySource}`;
|
||||
|
||||
let resultMarkdown = "";
|
||||
|
||||
// console.log("Parsed query", parsedQuery);
|
||||
// Let's dispatch an event and see what happens
|
||||
const results = await events.dispatchEvent(
|
||||
eventName,
|
||||
{ query: parsedQuery, pageName: pageMeta.name },
|
||||
30 * 1000,
|
||||
);
|
||||
if (results.length === 0) {
|
||||
// This means there was no handler for the event which means it's unsupported
|
||||
return {
|
||||
html:
|
||||
`**Error:** Unsupported query source '${parsedQuery.querySource}'`,
|
||||
};
|
||||
} else {
|
||||
const allResults = results.flat();
|
||||
if (allResults.length === 0) {
|
||||
resultMarkdown = "No results";
|
||||
} else {
|
||||
if (parsedQuery.render) {
|
||||
// Configured a custom rendering template, let's use it!
|
||||
const rendered = await renderTemplate(
|
||||
pageMeta,
|
||||
parsedQuery.render,
|
||||
allResults,
|
||||
);
|
||||
resultMarkdown = rendered.trim();
|
||||
} else {
|
||||
// TODO: At this point it's a bit pointless to first render a markdown table, and then convert that to HTML
|
||||
// We should just render the HTML table directly
|
||||
resultMarkdown = jsonToMDTable(allResults);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Parse markdown to a ParseTree
|
||||
const mdTree = await markdown.parseMarkdown(resultMarkdown);
|
||||
// And then render it to HTML
|
||||
const html = renderMarkdownToHtml(mdTree, { smartHardBreak: true });
|
||||
return {
|
||||
html: await wrapHTML(`
|
||||
${parsedQuery.render ? "" : `<div class="sb-table-widget">`}
|
||||
${html}
|
||||
${parsedQuery.render ? "" : `</div>`}
|
||||
`),
|
||||
script: await prepareJS(),
|
||||
};
|
||||
} catch (e: any) {
|
||||
return {
|
||||
html: await wrapHTML(`<b>Error:</b> ${e.message}`),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { WidgetContent } from "$sb/app_event.ts";
|
||||
import { editor, handlebars, markdown, space, YAML } from "$sb/syscalls.ts";
|
||||
import { renderMarkdownToHtml } from "../markdown/markdown_render.ts";
|
||||
import { prepareJS, wrapHTML } from "./util.ts";
|
||||
|
||||
type TemplateConfig = {
|
||||
// Pull the template from a page
|
||||
page?: string;
|
||||
// Or use a string directly
|
||||
template?: string;
|
||||
// Optional argument to pass
|
||||
value?: any;
|
||||
// If true, don't render the template, just use it as-is
|
||||
raw?: boolean;
|
||||
};
|
||||
|
||||
export async function widget(bodyText: string): Promise<WidgetContent> {
|
||||
const pageMeta = await space.getPageMeta(await editor.getCurrentPage());
|
||||
|
||||
try {
|
||||
const config: TemplateConfig = await YAML.parse(bodyText);
|
||||
let templateText = config.template || "";
|
||||
if (config.page) {
|
||||
let page = config.page;
|
||||
if (!page) {
|
||||
throw new Error("Missing `page`");
|
||||
}
|
||||
|
||||
if (page.startsWith("[[")) {
|
||||
page = page.slice(2, -2);
|
||||
}
|
||||
templateText = await space.readPage(page);
|
||||
}
|
||||
|
||||
const rendered = config.raw
|
||||
? templateText
|
||||
: await handlebars.renderTemplate(
|
||||
templateText,
|
||||
config.value,
|
||||
{
|
||||
page: pageMeta,
|
||||
},
|
||||
);
|
||||
const parsedMarkdown = await markdown.parseMarkdown(rendered);
|
||||
const html = renderMarkdownToHtml(parsedMarkdown, {
|
||||
smartHardBreak: true,
|
||||
});
|
||||
|
||||
return {
|
||||
html: await wrapHTML(html),
|
||||
script: await prepareJS(),
|
||||
};
|
||||
} catch (e: any) {
|
||||
return {
|
||||
html: `<b>Error:</b> ${e.message}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { asset } from "$sb/syscalls.ts";
|
||||
import { panelHtml } from "../../web/components/panel_html.ts";
|
||||
|
||||
export async function prepareJS() {
|
||||
const iframeJS = await asset.readAsset("assets/common.js");
|
||||
|
||||
return `
|
||||
const panelHtml = \`${panelHtml}\`;
|
||||
${iframeJS}
|
||||
`;
|
||||
}
|
||||
|
||||
export async function wrapHTML(html: string): Promise<string> {
|
||||
const css = await asset.readAsset("assets/style.css");
|
||||
|
||||
return `
|
||||
<!-- Load SB's own CSS here too -->
|
||||
<link rel="stylesheet" href="/.client/main.css" />
|
||||
<!-- In addition to some custom CSS -->
|
||||
<style>${css}</style>
|
||||
<!-- Wrap the whole thing in something SB-like to get access to styles -->
|
||||
<div id="sb-main"><div id="sb-editor"><div class="cm-editor">
|
||||
<!-- And add an edit button -->
|
||||
<div id="button-bar">
|
||||
<button id="reload-button" title="Reload"><svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="23 4 23 10 17 10"></polyline><polyline points="1 20 1 14 7 14"></polyline><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"></path></svg></button>
|
||||
<button id="edit-button" title="Edit"><svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-edit"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"></path></svg></button>
|
||||
</div>
|
||||
${html}
|
||||
</div></div></div>
|
||||
`;
|
||||
}
|
||||
Reference in New Issue
Block a user