Templates 2.0 (#636)

Templates 2.0 and a whole bunch of other refactoring
This commit is contained in:
Zef Hemel
2024-01-20 19:16:07 +01:00
committed by GitHub
parent 0a6a0016a2
commit f30b1d3418
171 changed files with 2038 additions and 1628 deletions
-114
View File
@@ -1,114 +0,0 @@
import { CompleteEvent, SlashCompletion } from "$sb/app_event.ts";
import { PageMeta } from "$sb/types.ts";
import { editor, events, markdown, space } from "$sb/syscalls.ts";
import type {
AttributeCompleteEvent,
AttributeCompletion,
} from "../index/attributes.ts";
import { queryObjects } from "../index/plug_api.ts";
import { TemplateObject } from "./types.ts";
import { loadPageObject } from "./template.ts";
import { renderTemplate } from "./api.ts";
import { prepareFrontmatterDispatch } from "$sb/lib/frontmatter.ts";
import { buildHandebarOptions } from "./util.ts";
export async function templateVariableComplete(completeEvent: CompleteEvent) {
const match = /\{\{([\w@]*)$/.exec(completeEvent.linePrefix);
if (!match) {
return null;
}
const handlebarOptions = buildHandebarOptions({ name: "" } as PageMeta);
let allCompletions: any[] = Object.keys(handlebarOptions.helpers).map(
(name) => ({ label: name, detail: "helper" }),
);
allCompletions = allCompletions.concat(
Object.keys(handlebarOptions.data).map((key) => ({
label: `@${key}`,
detail: "global variable",
})),
);
const completions = (await events.dispatchEvent(
`attribute:complete:_`,
{
source: "",
prefix: match[1],
} as AttributeCompleteEvent,
)).flat() as AttributeCompletion[];
allCompletions = allCompletions.concat(
attributeCompletionsToCMCompletion(completions),
);
return {
from: completeEvent.pos - match[1].length,
options: allCompletions,
};
}
export async function templateSlashComplete(
completeEvent: CompleteEvent,
): Promise<SlashCompletion[]> {
const allTemplates = await queryObjects<TemplateObject>("template", {
// Only return templates that have a trigger and are not expliclty disabled
filter: ["and", ["attr", "trigger"], ["!=", ["attr", "enabled"], [
"boolean",
false,
]]],
}, 5);
return allTemplates.map((template) => ({
label: template.trigger!,
detail: "template",
templatePage: template.ref,
pageName: completeEvent.pageName,
invoke: "template.insertSlashTemplate",
}));
}
export async function insertSlashTemplate(slashCompletion: SlashCompletion) {
const pageObject = await loadPageObject(slashCompletion.pageName);
const templateText = await space.readPage(slashCompletion.templatePage);
let { renderedFrontmatter, text } = await renderTemplate(
templateText,
pageObject,
);
let cursorPos = await editor.getCursor();
if (renderedFrontmatter) {
renderedFrontmatter = renderedFrontmatter.trim();
const pageText = await editor.getText();
const tree = await markdown.parseMarkdown(pageText);
const dispatch = await prepareFrontmatterDispatch(
tree,
renderedFrontmatter,
);
if (cursorPos === 0) {
dispatch.selection = { anchor: renderedFrontmatter.length + 9 };
}
await editor.dispatch(dispatch);
}
cursorPos = await editor.getCursor();
const carretPos = text.indexOf("|^|");
text = text.replace("|^|", "");
await editor.insertAtCursor(text);
if (carretPos !== -1) {
await editor.moveCursor(cursorPos + carretPos);
}
}
export function attributeCompletionsToCMCompletion(
completions: AttributeCompletion[],
) {
return completions.map(
(completion) => ({
label: completion.name,
detail: `${completion.attributeType} (${completion.source})`,
type: "attribute",
}),
);
}
+43
View File
@@ -0,0 +1,43 @@
import { LintEvent } from "$sb/app_event.ts";
import { LintDiagnostic } from "$sb/types.ts";
import { findNodeOfType } from "$sb/lib/tree.ts";
import { FrontmatterConfig } from "./types.ts";
import { extractFrontmatter } from "$sb/lib/frontmatter.ts";
export async function lintTemplateFrontmatter(
{ tree }: LintEvent,
): Promise<LintDiagnostic[]> {
const diagnostics: LintDiagnostic[] = [];
const frontmatter = await extractFrontmatter(tree);
// Just looking this up again for the purposes of error reporting
const frontmatterNode = findNodeOfType(tree, "FrontMatterCode")!;
if (!frontmatter.tags?.includes("template")) {
return [];
}
try {
// Just parse to make sure it's valid
FrontmatterConfig.parse(frontmatter);
} catch (e: any) {
if (e.message.startsWith("[")) { // We got a zod error
const zodErrors = JSON.parse(e.message);
for (const zodError of zodErrors) {
console.log("Zod validation error", zodError);
diagnostics.push({
from: frontmatterNode.from!,
to: frontmatterNode.to!,
message: `Attribute ${zodError.path.join(".")}: ${zodError.message}`,
severity: "error",
});
}
} else {
diagnostics.push({
from: frontmatterNode.from!,
to: frontmatterNode.to!,
message: e.message,
severity: "error",
});
}
}
return diagnostics;
}
+226
View File
@@ -0,0 +1,226 @@
import { editor, handlebars, space } from "$sb/syscalls.ts";
import { PageMeta } from "$sb/types.ts";
import { getObjectByRef, queryObjects } from "../index/plug_api.ts";
import { FrontmatterConfig, TemplateObject } from "./types.ts";
import { renderTemplate } from "./api.ts";
export async function newPageCommand(
_cmdDef: any,
templateName?: string,
askName = true,
) {
if (!templateName) {
const allPageTemplates = await listPageTemplates();
// console.log("All page templates", allPageTemplates);
const selectedTemplate = await selectPageTemplate(allPageTemplates);
if (!selectedTemplate) {
return;
}
templateName = selectedTemplate.ref;
}
console.log("Selected template", templateName);
await instantiatePageTemplate(templateName!, undefined, askName);
}
function listPageTemplates() {
return queryObjects<TemplateObject>("template", {
// where hooks.newPage exists
filter: ["attr", ["attr", "hooks"], "newPage"],
});
}
// Invoked when a new page is created
export async function newPage(pageName: string) {
console.log("Asked to setup a new page for", pageName);
const allPageTemplatesMatchingPrefix = (await listPageTemplates()).filter(
(templateObject) => {
const forPrefix = templateObject.hooks?.newPage?.forPrefix;
return forPrefix && pageName.startsWith(forPrefix);
},
);
// console.log("Matching templates", allPageTemplatesMatchingPrefix);
if (allPageTemplatesMatchingPrefix.length === 0) {
// No matching templates, that's ok, we'll just start with an empty page, so let's just return
return;
}
if (allPageTemplatesMatchingPrefix.length === 1) {
// Only one matching template, let's use it
await instantiatePageTemplate(
allPageTemplatesMatchingPrefix[0].ref,
pageName,
false,
);
} else {
// Let's offer a choice
const selectedTemplate = await selectPageTemplate(
allPageTemplatesMatchingPrefix,
);
if (!selectedTemplate) {
// No choice made? We'll start out empty
return;
}
await instantiatePageTemplate(
selectedTemplate.ref,
pageName,
false,
);
}
}
function selectPageTemplate(options: TemplateObject[]) {
return editor.filterBox(
"Page template",
options.map((templateObj) => {
const niceName = templateObj.ref.split("/").pop()!;
return {
...templateObj,
description: templateObj.description || templateObj.ref,
name: templateObj.displayName || niceName,
};
}),
`Select the template to create a new page from`,
);
}
async function instantiatePageTemplate(
templateName: string,
intoCurrentPage: string | undefined,
askName: boolean,
) {
const templateText = await space.readPage(templateName!);
console.log(
"Instantiating page template",
templateName,
intoCurrentPage,
askName,
);
const tempPageMeta: PageMeta = {
tag: "page",
ref: "",
name: "",
created: "",
lastModified: "",
perm: "rw",
};
// Just used to extract the frontmatter
const { frontmatter } = await renderTemplate(
templateText,
tempPageMeta,
);
let frontmatterConfig: FrontmatterConfig;
try {
frontmatterConfig = FrontmatterConfig.parse(frontmatter!);
} catch (e: any) {
await editor.flashNotification(
`Error parsing template frontmatter for ${templateName}: ${e.message}`,
);
return;
}
const newPageConfig = frontmatterConfig.hooks!.newPage!;
let pageName: string | undefined = intoCurrentPage ||
await replaceTemplateVars(
newPageConfig.suggestedName || "",
tempPageMeta,
);
if (!intoCurrentPage && askName && newPageConfig.confirmName !== false) {
pageName = await editor.prompt(
"Name of new page",
await replaceTemplateVars(
newPageConfig.suggestedName || "",
tempPageMeta,
),
);
if (!pageName) {
return;
}
}
tempPageMeta.name = pageName;
if (!intoCurrentPage) {
// Check if page exists, but only if we're not forcing the name (which only happens when we know that we're creating a new page already)
try {
// Fails if doesn't exist
await space.getPageMeta(pageName);
// So, page exists
if (newPageConfig.openIfExists) {
console.log("Page already exists, navigating there");
await editor.navigate(pageName);
return;
}
// let's warn
if (
!await editor.confirm(
`Page ${pageName} already exists, are you sure you want to override it?`,
)
) {
// Just navigate there without instantiating
return editor.navigate(pageName);
}
} catch {
// The preferred scenario, let's keep going
}
}
const { text: pageText, renderedFrontmatter } = await renderTemplate(
templateText,
tempPageMeta,
);
let fullPageText = renderedFrontmatter
? "---\n" + renderedFrontmatter + "---\n" + pageText
: pageText;
const carretPos = fullPageText.indexOf("|^|");
fullPageText = fullPageText.replace("|^|", "");
if (intoCurrentPage) {
await editor.insertAtCursor(fullPageText);
if (carretPos !== -1) {
await editor.moveCursor(carretPos);
}
} else {
await space.writePage(
pageName,
fullPageText,
);
await editor.navigate(pageName, carretPos !== -1 ? carretPos : undefined);
}
}
export async function loadPageObject(pageName?: string): Promise<PageMeta> {
if (!pageName) {
return {
ref: "",
name: "",
tags: ["page"],
lastModified: "",
created: "",
} as PageMeta;
}
return (await getObjectByRef<PageMeta>(
pageName,
"page",
pageName,
)) || {
ref: pageName,
name: pageName,
tags: ["page"],
lastModified: "",
created: "",
} as PageMeta;
}
export function replaceTemplateVars(
s: string,
pageMeta: PageMeta,
): Promise<string> {
return handlebars.renderTemplate(s, {}, { page: pageMeta });
}
+155
View File
@@ -0,0 +1,155 @@
import { CompleteEvent, SlashCompletion } from "$sb/app_event.ts";
import { editor, markdown, space } from "$sb/syscalls.ts";
import type { AttributeCompletion } from "../index/attributes.ts";
import { queryObjects } from "../index/plug_api.ts";
import { TemplateObject } from "./types.ts";
import { loadPageObject } from "./page.ts";
import { renderTemplate } from "./api.ts";
import { prepareFrontmatterDispatch } from "$sb/lib/frontmatter.ts";
import { SnippetConfig } from "./types.ts";
import { snippet } from "@codemirror/autocomplete";
export async function snippetSlashComplete(
completeEvent: CompleteEvent,
): Promise<SlashCompletion[]> {
const allTemplates = await queryObjects<TemplateObject>("template", {
// where hooks.snippet.slashCommand exists
filter: ["attr", ["attr", ["attr", "hooks"], "snippet"], "slashCommand"],
}, 5);
return allTemplates.map((template) => {
const snippetTemplate = template.hooks!.snippet!;
return {
label: snippetTemplate.slashCommand,
detail: template.description,
templatePage: template.ref,
pageName: completeEvent.pageName,
invoke: "template.insertSnippetTemplate",
};
});
}
export async function insertSnippetTemplate(slashCompletion: SlashCompletion) {
const pageObject = await loadPageObject(
slashCompletion.pageName,
);
const templateText = await space.readPage(slashCompletion.templatePage);
let { renderedFrontmatter, text: replacementText, frontmatter } =
await renderTemplate(
templateText,
pageObject,
);
let snippetTemplate: SnippetConfig;
try {
snippetTemplate = SnippetConfig.parse(frontmatter.hooks!.snippet!);
} catch (e: any) {
console.error(
`Invalid template configuration for ${slashCompletion.templatePage}:`,
e.message,
);
await editor.flashNotification(
`Invalid template configuration for ${slashCompletion.templatePage}, won't insert snippet`,
"error",
);
return;
}
let cursorPos = await editor.getCursor();
if (renderedFrontmatter) {
renderedFrontmatter = renderedFrontmatter.trim();
const pageText = await editor.getText();
const tree = await markdown.parseMarkdown(pageText);
const dispatch = await prepareFrontmatterDispatch(
tree,
renderedFrontmatter,
);
if (cursorPos === 0) {
dispatch.selection = { anchor: renderedFrontmatter.length + 9 };
}
await editor.dispatch(dispatch);
// update cursor position
cursorPos = await editor.getCursor();
}
if (snippetTemplate.insertAt) {
switch (snippetTemplate.insertAt) {
case "page-start":
await editor.moveCursor(0);
break;
case "page-end":
await editor.moveCursor((await editor.getText()).length);
break;
case "line-start": {
const pageText = await editor.getText();
let startOfLine = cursorPos;
while (startOfLine > 0 && pageText[startOfLine - 1] !== "\n") {
startOfLine--;
}
await editor.moveCursor(startOfLine);
break;
}
case "line-end": {
const pageText = await editor.getText();
let endOfLine = cursorPos;
while (endOfLine < pageText.length && pageText[endOfLine] !== "\n") {
endOfLine++;
}
await editor.moveCursor(endOfLine);
break;
}
default:
// Deliberate no-op
}
}
cursorPos = await editor.getCursor();
if (snippetTemplate.matchRegex) {
const pageText = await editor.getText();
// Regex matching mode
const matchRegex = new RegExp(snippetTemplate.matchRegex);
let startOfLine = cursorPos;
while (startOfLine > 0 && pageText[startOfLine - 1] !== "\n") {
startOfLine--;
}
let currentLine = pageText.slice(startOfLine, cursorPos);
const emptyLine = !currentLine;
currentLine = currentLine.replace(matchRegex, replacementText);
await editor.dispatch({
changes: {
from: startOfLine,
to: cursorPos,
insert: currentLine,
},
selection: emptyLine
? {
anchor: startOfLine + currentLine.length,
}
: undefined,
});
} else {
const carretPos = replacementText.indexOf("|^|");
replacementText = replacementText.replace("|^|", "");
await editor.insertAtCursor(replacementText);
if (carretPos !== -1) {
await editor.moveCursor(cursorPos + carretPos);
}
}
}
export function attributeCompletionsToCMCompletion(
completions: AttributeCompletion[],
) {
return completions.map(
(completion) => ({
label: completion.name,
detail: `${completion.attributeType} (${completion.source})`,
type: "attribute",
}),
);
}
+22 -106
View File
@@ -3,135 +3,51 @@ functions:
# API
renderTemplate:
path: api.ts:renderTemplate
cleanTemplate:
path: api.ts:cleanTemplate
# Used by various slash commands
insertTemplateText:
path: template.ts:insertTemplateText
# Indexing
indexTemplate:
path: ./index.ts:indexTemplate
path: index.ts:indexTemplate
events:
# Special event only triggered for template pages
- page:indexTemplate
# Completion
templateSlashCommand:
path: ./complete.ts:templateSlashComplete
path: snippet.ts:snippetSlashComplete
events:
- slash:complete
insertSlashTemplate:
path: ./complete.ts:insertSlashTemplate
insertSnippetTemplate:
path: snippet.ts:insertSnippetTemplate
handlebarHelperComplete:
path: ./complete.ts:templateVariableComplete
path: var.ts:templateVariableComplete
events:
- editor:complete
applyLineReplace:
path: ./template.ts:applyLineReplace
insertFrontMatter:
redirect: insertTemplateText
slashCommand:
name: frontmatter
description: Insert page frontmatter
value: |
---
|^|
---
makeH1:
redirect: applyLineReplace
slashCommand:
name: h1
description: Turn line into h1 header
match: "^#*\\s*"
replace: "# "
makeH2:
redirect: applyLineReplace
slashCommand:
name: h2
description: Turn line into h2 header
match: "^#*\\s*"
replace: "## "
makeH3:
redirect: applyLineReplace
slashCommand:
name: h3
description: Turn line into h3 header
match: "^#*\\s*"
replace: "### "
makeH4:
redirect: applyLineReplace
slashCommand:
name: h4
description: Turn line into h4 header
match: "^#*\\s*"
replace: "#### "
insertCodeBlock:
redirect: insertTemplateText
slashCommand:
name: code
description: Insert fenced code block
value: |
```|^|
```
# Widget
templateWidget: # Legacy
path: template_block.ts:widget
codeWidget: template
renderMode: markdown
insertHRTemplate:
redirect: insertTemplateText
slashCommand:
name: hr
description: Insert a horizontal rule
value: "---"
insertTable:
redirect: insertTemplateText
slashCommand:
name: table
description: Insert a table
boost: -1 # Low boost because it's likely not very commonly used
value: |
| Header A | Header B |
|----------|----------|
| Cell A|^| | Cell B |
quickNoteCommand:
path: ./template.ts:quickNoteCommand
command:
name: "Quick Note"
key: "Alt-Shift-n"
priority: 3
dailyNoteCommand:
path: ./template.ts:dailyNoteCommand
command:
name: "Open Daily Note"
key: "Alt-Shift-d"
weeklyNoteCommand:
path: ./template.ts:weeklyNoteCommand
command:
name: "Open Weekly Note"
key: "Alt-Shift-w"
# API invoked when a new page is created
newPage:
path: page.ts:newPage
# Commands
newPageCommand:
path: ./template.ts:newPageCommand
path: page.ts:newPageCommand
command:
name: "Page: From Template"
key: "Alt-Shift-t"
insertTodayCommand:
path: "./template.ts:insertTemplateText"
slashCommand:
name: today
description: Insert today's date
value: "{{today}}"
insertTomorrowCommand:
path: "./template.ts:insertTemplateText"
slashCommand:
name: tomorrow
description: Insert tomorrow's date
value: "{{tomorrow}}"
# Lint
lintTemplateFrontmatter:
path: lint.ts:lintTemplateFrontmatter
events:
- editor:lint
-274
View File
@@ -1,274 +0,0 @@
import { editor, handlebars, space } from "$sb/syscalls.ts";
import { niceDate, niceTime } from "$sb/lib/dates.ts";
import { readSettings } from "$sb/lib/settings_page.ts";
import { cleanPageRef } from "$sb/lib/resolve.ts";
import { PageMeta } from "$sb/types.ts";
import { getObjectByRef, queryObjects } from "../index/plug_api.ts";
import { TemplateObject } from "./types.ts";
import { renderTemplate } from "./api.ts";
export async function newPageCommand(
_cmdDef: any,
templateName?: string,
askName = true,
) {
if (!templateName) {
const allPageTemplates = await queryObjects<TemplateObject>("template", {
// Only return templates that have a trigger
filter: ["=", ["attr", "type"], ["string", "page"]],
});
const selectedTemplate = await editor.filterBox(
"Page template",
allPageTemplates
.map((pageMeta) => ({
...pageMeta,
name: pageMeta.displayName || pageMeta.ref,
})),
`Select the template to create a new page from (listing any page tagged with <tt>#template</tt> and 'page' set as 'type')`,
);
if (!selectedTemplate) {
return;
}
templateName = selectedTemplate.ref;
}
console.log("Selected template", templateName);
const templateText = await space.readPage(templateName!);
const tempPageMeta: PageMeta = {
tag: "page",
ref: "",
name: "",
created: "",
lastModified: "",
perm: "rw",
};
// Just used to extract the frontmatter
const { frontmatter } = await renderTemplate(
templateText,
tempPageMeta,
);
let pageName: string | undefined = await replaceTemplateVars(
frontmatter?.pageName || "",
tempPageMeta,
);
if (askName) {
pageName = await editor.prompt(
"Name of new page",
await replaceTemplateVars(frontmatter?.pageName || "", tempPageMeta),
);
if (!pageName) {
return;
}
}
tempPageMeta.name = pageName;
try {
// Fails if doesn't exist
await space.getPageMeta(pageName);
// So, page exists, let's warn
if (
!await editor.confirm(
`Page ${pageName} already exists, are you sure you want to override it?`,
)
) {
// Just navigate there without instantiating
return editor.navigate(pageName);
}
} catch {
// The preferred scenario, let's keep going
}
const { text: pageText, renderedFrontmatter } = await renderTemplate(
templateText,
tempPageMeta,
);
let fullPageText = renderedFrontmatter
? "---\n" + renderedFrontmatter + "---\n" + pageText
: pageText;
const carretPos = fullPageText.indexOf("|^|");
fullPageText = fullPageText.replace("|^|", "");
await space.writePage(
pageName,
fullPageText,
);
await editor.navigate(pageName, carretPos !== -1 ? carretPos : undefined);
}
export async function loadPageObject(pageName?: string): Promise<PageMeta> {
if (!pageName) {
return {
ref: "",
name: "",
tags: ["page"],
lastModified: "",
created: "",
} as PageMeta;
}
return (await getObjectByRef<PageMeta>(
pageName,
"page",
pageName,
)) || {
ref: pageName,
name: pageName,
tags: ["page"],
lastModified: "",
created: "",
} as PageMeta;
}
export function replaceTemplateVars(
s: string,
pageMeta: PageMeta,
): Promise<string> {
return handlebars.renderTemplate(s, {}, { page: pageMeta });
}
export async function quickNoteCommand() {
const { quickNotePrefix } = await readSettings({
quickNotePrefix: "📥 ",
});
const date = niceDate(new Date());
const time = niceTime(new Date());
const pageName = `${quickNotePrefix}${date} ${time}`;
await editor.navigate(pageName);
}
export async function dailyNoteCommand() {
const { dailyNoteTemplate, dailyNotePrefix } = await readSettings({
dailyNoteTemplate: "[[template/page/Daily Note]]",
dailyNotePrefix: "📅 ",
});
const date = niceDate(new Date());
const pageName = `${dailyNotePrefix}${date}`;
let carretPos = 0;
try {
await space.getPageMeta(pageName);
} catch {
// Doesn't exist, let's create
let dailyNoteTemplateText = "";
try {
dailyNoteTemplateText = await space.readPage(
cleanPageRef(dailyNoteTemplate),
);
carretPos = dailyNoteTemplateText.indexOf("|^|");
if (carretPos === -1) {
carretPos = 0;
}
dailyNoteTemplateText = dailyNoteTemplateText.replace("|^|", "");
} catch {
console.warn(`No daily note template found at ${dailyNoteTemplate}`);
}
await space.writePage(
pageName,
await replaceTemplateVars(dailyNoteTemplateText, {
tag: "page",
ref: pageName,
name: pageName,
created: "",
lastModified: "",
perm: "rw",
}),
);
}
await editor.navigate(pageName, carretPos);
}
function getWeekStartDate(monday = false) {
const d = new Date();
const day = d.getDay();
let diff = d.getDate() - day;
if (monday) {
diff += day == 0 ? -6 : 1;
}
return new Date(d.setDate(diff));
}
export async function weeklyNoteCommand() {
const { weeklyNoteTemplate, weeklyNotePrefix, weeklyNoteMonday } =
await readSettings({
weeklyNoteTemplate: "[[template/page/Weekly Note]]",
weeklyNotePrefix: "🗓️ ",
weeklyNoteMonday: false,
});
let weeklyNoteTemplateText = "";
try {
weeklyNoteTemplateText = await space.readPage(
cleanPageRef(weeklyNoteTemplate),
);
} catch {
console.warn(`No weekly note template found at ${weeklyNoteTemplate}`);
}
const date = niceDate(getWeekStartDate(weeklyNoteMonday));
const pageName = `${weeklyNotePrefix}${date}`;
if (weeklyNoteTemplateText) {
try {
await space.getPageMeta(pageName);
} catch {
// Doesn't exist, let's create
await space.writePage(
pageName,
await replaceTemplateVars(weeklyNoteTemplateText, {
name: pageName,
ref: pageName,
tag: "page",
created: "",
lastModified: "",
perm: "rw",
}),
);
}
await editor.navigate(pageName);
} else {
await editor.navigate(pageName);
}
}
export async function insertTemplateText(cmdDef: any) {
const cursorPos = await editor.getCursor();
const page = await editor.getCurrentPage();
const pageMeta = await loadPageObject(page);
let templateText: string = cmdDef.value;
const carretPos = templateText.indexOf("|^|");
templateText = templateText.replace("|^|", "");
templateText = await replaceTemplateVars(templateText, pageMeta);
await editor.insertAtCursor(templateText);
if (carretPos !== -1) {
await editor.moveCursor(cursorPos + carretPos);
}
}
export async function applyLineReplace(cmdDef: any) {
const cursorPos = await editor.getCursor();
const text = await editor.getText();
const matchRegex = new RegExp(cmdDef.match);
let startOfLine = cursorPos;
while (startOfLine > 0 && text[startOfLine - 1] !== "\n") {
startOfLine--;
}
let currentLine = text.slice(startOfLine, cursorPos);
const emptyLine = !currentLine;
currentLine = currentLine.replace(matchRegex, cmdDef.replace);
await editor.dispatch({
changes: {
from: startOfLine,
to: cursorPos,
insert: currentLine,
},
selection: emptyLine
? {
anchor: startOfLine + currentLine.length,
}
: undefined,
});
}
+101
View File
@@ -0,0 +1,101 @@
import { markdown, space, YAML } from "$sb/syscalls.ts";
import { loadPageObject, replaceTemplateVars } from "./page.ts";
import { CodeWidgetContent, PageMeta } from "$sb/types.ts";
import { renderTemplate } from "./plug_api.ts";
import { renderToText } from "$sb/lib/tree.ts";
import { rewritePageRefs, rewritePageRefsInString } from "$sb/lib/resolve.ts";
import { performQuery } from "../query/widget.ts";
import { parseQuery } from "$sb/lib/parse-query.ts";
type TemplateWidgetConfig = {
// Pull the template from a page
page?: string;
// Or use a string directly
template?: string;
// To feed data into the template you can either use a concrete value
value?: any;
// Or a query
query?: string;
// If true, don't render the template, just use it as-is
raw?: boolean;
};
export async function widget(
bodyText: string,
pageName: string,
): Promise<CodeWidgetContent> {
const pageMeta: PageMeta = await loadPageObject(pageName);
try {
const config: TemplateWidgetConfig = await YAML.parse(bodyText);
let templateText = config.template || "";
let templatePage = config.page;
if (templatePage) {
// Rewrite federation page references
templatePage = rewritePageRefsInString(templatePage, pageName);
if (templatePage.startsWith("[[")) {
templatePage = templatePage.slice(2, -2);
}
if (!templatePage) {
throw new Error("No template page specified");
}
try {
templateText = await space.readPage(templatePage);
} catch (e: any) {
if (e.message === "Not found") {
throw new Error(`Template page ${templatePage} not found`);
}
}
}
let value: any;
if (config.value) {
value = JSON.parse(
await replaceTemplateVars(JSON.stringify(config.value), pageMeta),
);
}
if (config.query) {
const parsedQuery = await parseQuery(
await replaceTemplateVars(config.query, pageMeta),
);
value = await performQuery(parsedQuery, pageMeta);
}
let { text: rendered } = config.raw
? { text: templateText }
: await renderTemplate(
templateText,
pageMeta,
value,
);
if (templatePage) {
const parsedMarkdown = await markdown.parseMarkdown(rendered);
rewritePageRefs(parsedMarkdown, templatePage);
rendered = renderToText(parsedMarkdown);
}
return {
markdown: rendered,
buttons: [{
description: "Edit",
svg:
`<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" 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>`,
invokeFunction: "query.editButton",
}, {
description: "Reload",
svg:
`<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-refresh-cw"><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>`,
invokeFunction: "query.refreshAllWidgets",
}],
};
} catch (e: any) {
return {
markdown: `**Error:** ${e.message}`,
};
}
}
+94 -11
View File
@@ -1,17 +1,100 @@
import { ObjectValue } from "$sb/types.ts";
import { z, ZodEffects } from "zod";
export type TemplateFrontmatter = {
displayName?: string;
type?: "page";
export const CommandConfig = z.object({
command: z.string().optional(),
key: z.string().optional(),
mac: z.string().optional(),
});
export type CommandConfig = z.infer<typeof CommandConfig>;
/**
* Used for creating new pages using {[Page: From Template]} command
*/
export const NewPageConfig = refineCommand(
z.object({
// Suggested name for the new page, can use template placeholders
suggestedName: z.string().optional(),
// Suggest (or auto use) this template for a specific prefix
forPrefix: z.string().optional(),
// Confirm the name before creating
confirmName: z.boolean().optional(),
// If the page already exists, open it instead of creating a new one
openIfExists: z.boolean().optional(),
}).strict().merge(CommandConfig),
);
export type NewPageConfig = z.infer<typeof NewPageConfig>;
/**
* Represents a snippet
*/
export const SnippetConfig = refineCommand(
z.object({
slashCommand: z.string(), // trigger
// Regex match to apply (implicitly makes the body the regex replacement)
matchRegex: z.string().optional(),
insertAt: z.enum([
"cursor",
"line-start",
"line-end",
"page-start",
"page-end",
]).optional(), // defaults to cursor
}).strict().merge(CommandConfig),
);
/**
* Ensures that 'command' is present if either 'key' or 'mac' is present for a particular object
* @param o object to 'refine' with this constraint
* @returns
*/
function refineCommand<T extends typeof CommandConfig>(o: T): ZodEffects<T> {
return o.refine((data) => {
// Check if either 'key' or 'mac' is present
const hasKeyOrMac = data.key !== undefined || data.mac !== undefined;
// Ensure 'command' is present if either 'key' or 'mac' is present
return !hasKeyOrMac || data.command !== undefined;
}, {
message:
"Attribute 'command' is required when specifying a key binding via 'key' and/or 'mac'.",
});
}
export type SnippetConfig = z.infer<typeof SnippetConfig>;
export const WidgetConfig = z.object({
where: z.string(),
priority: z.number().optional(),
});
export type WidgetConfig = z.infer<typeof WidgetConfig>;
export const HooksConfig = z.object({
top: WidgetConfig.optional(),
bottom: WidgetConfig.optional(),
newPage: NewPageConfig.optional(),
snippet: SnippetConfig.optional(),
}).strict();
export type HooksConfig = z.infer<typeof HooksConfig>;
export const FrontmatterConfig = z.object({
// Used for matching in page navigator
displayName: z.string().optional(),
tags: z.union([z.string(), z.array(z.string())]).optional(),
// For use in the template selector slash commands and other avenues
description: z.string().optional(),
// Frontmatter can be encoded as an object (in which case we'll serialize it) or as a string
frontmatter?: Record<string, any> | string;
frontmatter: z.union([z.record(z.unknown()), z.string()]).optional(),
// Specific for slash templates
trigger?: string;
hooks: HooksConfig.optional(),
});
// Specific for frontmatter templates
where?: string; // expression (SB query style)
priority?: number; // When multiple templates match, the one with the highest priority is used
};
export type FrontmatterConfig = z.infer<typeof FrontmatterConfig>;
export type TemplateObject = ObjectValue<TemplateFrontmatter>;
export type TemplateObject = ObjectValue<FrontmatterConfig>;
+44
View File
@@ -0,0 +1,44 @@
import { CompleteEvent } from "$sb/app_event.ts";
import { PageMeta } from "$sb/types.ts";
import { events } from "$sb/syscalls.ts";
import { buildHandebarOptions } from "./util.ts";
import {
AttributeCompleteEvent,
AttributeCompletion,
} from "../index/attributes.ts";
import { attributeCompletionsToCMCompletion } from "./snippet.ts";
export async function templateVariableComplete(completeEvent: CompleteEvent) {
const match = /\{\{([\w@]*)$/.exec(completeEvent.linePrefix);
if (!match) {
return null;
}
const handlebarOptions = buildHandebarOptions({ name: "" } as PageMeta);
let allCompletions: any[] = Object.keys(handlebarOptions.helpers).map(
(name) => ({ label: name, detail: "helper" }),
);
allCompletions = allCompletions.concat(
Object.keys(handlebarOptions.data).map((key) => ({
label: `@${key}`,
detail: "global variable",
})),
);
const completions = (await events.dispatchEvent(
`attribute:complete:_`,
{
source: "",
prefix: match[1],
} as AttributeCompleteEvent,
)).flat() as AttributeCompletion[];
allCompletions = allCompletions.concat(
attributeCompletionsToCMCompletion(completions),
);
return {
from: completeEvent.pos - match[1].length,
options: allCompletions,
};
}