Templates 2.0 (#636)
Templates 2.0 and a whole bunch of other refactoring
@ -48,13 +48,7 @@ export async function runPlug(
|
|||||||
}, app.fetch);
|
}, app.fetch);
|
||||||
|
|
||||||
if (functionName) {
|
if (functionName) {
|
||||||
const [plugName, funcName] = functionName.split(".");
|
const result = await serverSystem.system.invokeFunction(functionName, args);
|
||||||
|
|
||||||
const plug = serverSystem.system.loadedPlugs.get(plugName);
|
|
||||||
if (!plug) {
|
|
||||||
throw new Error(`Plug ${plugName} not found`);
|
|
||||||
}
|
|
||||||
const result = await plug.invoke(funcName, args);
|
|
||||||
await serverSystem.close();
|
await serverSystem.close();
|
||||||
serverSystem.kvPrimitives.close();
|
serverSystem.kvPrimitives.close();
|
||||||
serverController.abort();
|
serverController.abort();
|
||||||
|
@ -6,3 +6,11 @@ A list of built-in settings [[!silverbullet.md/SETTINGS|can be found here]].
|
|||||||
indexPage: index
|
indexPage: index
|
||||||
\`\`\`
|
\`\`\`
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
export const INDEX_TEMPLATE =
|
||||||
|
`Hello! And welcome to your brand new SilverBullet space!
|
||||||
|
|
||||||
|
\`\`\`template
|
||||||
|
page: "[[!silverbullet.md/Getting Started]]"
|
||||||
|
\`\`\`
|
||||||
|
`;
|
@ -71,7 +71,12 @@ export {
|
|||||||
Text,
|
Text,
|
||||||
Transaction,
|
Transaction,
|
||||||
} from "@codemirror/state";
|
} from "@codemirror/state";
|
||||||
export type { ChangeSpec, Extension, StateCommand } from "@codemirror/state";
|
export type {
|
||||||
|
ChangeSpec,
|
||||||
|
Compartment,
|
||||||
|
Extension,
|
||||||
|
StateCommand,
|
||||||
|
} from "@codemirror/state";
|
||||||
export {
|
export {
|
||||||
codeFolding,
|
codeFolding,
|
||||||
defaultHighlightStyle,
|
defaultHighlightStyle,
|
||||||
@ -132,3 +137,5 @@ export {
|
|||||||
export { mime } from "https://deno.land/x/mimetypes@v1.0.0/mod.ts";
|
export { mime } from "https://deno.land/x/mimetypes@v1.0.0/mod.ts";
|
||||||
|
|
||||||
export { compile as gitIgnoreCompiler } from "https://esm.sh/gitignore-parser@0.0.2";
|
export { compile as gitIgnoreCompiler } from "https://esm.sh/gitignore-parser@0.0.2";
|
||||||
|
|
||||||
|
export { z } from "https://deno.land/x/zod@v3.22.4/mod.ts";
|
||||||
|
@ -34,6 +34,7 @@ export const builtinLanguages: Record<string, Language> = {
|
|||||||
"meta": StreamLanguage.define(yamlLanguage),
|
"meta": StreamLanguage.define(yamlLanguage),
|
||||||
"yaml": StreamLanguage.define(yamlLanguage),
|
"yaml": StreamLanguage.define(yamlLanguage),
|
||||||
"template": StreamLanguage.define(yamlLanguage),
|
"template": StreamLanguage.define(yamlLanguage),
|
||||||
|
"block": StreamLanguage.define(yamlLanguage),
|
||||||
"embed": StreamLanguage.define(yamlLanguage),
|
"embed": StreamLanguage.define(yamlLanguage),
|
||||||
"data": StreamLanguage.define(yamlLanguage),
|
"data": StreamLanguage.define(yamlLanguage),
|
||||||
"toc": StreamLanguage.define(yamlLanguage),
|
"toc": StreamLanguage.define(yamlLanguage),
|
||||||
|
15
common/query_functions.ts
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
import { FunctionMap } from "$sb/types.ts";
|
||||||
|
import { builtinFunctions } from "$sb/lib/builtin_query_functions.ts";
|
||||||
|
|
||||||
|
export function buildQueryFunctions(allKnownPages: Set<string>): FunctionMap {
|
||||||
|
return {
|
||||||
|
...builtinFunctions,
|
||||||
|
pageExists: (name: string) => {
|
||||||
|
if (name.startsWith("!") || name.startsWith("{{")) {
|
||||||
|
// Let's assume federated pages exist, and ignore template variable ones
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return allKnownPages.has(name);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
@ -16,7 +16,7 @@ export async function ensureSpaceIndex(ds: DataStore, system: System<any>) {
|
|||||||
if (currentIndexVersion !== desiredIndexVersion && !indexOngoing) {
|
if (currentIndexVersion !== desiredIndexVersion && !indexOngoing) {
|
||||||
console.info("Performing a full space reindex, this could take a while...");
|
console.info("Performing a full space reindex, this could take a while...");
|
||||||
indexOngoing = true;
|
indexOngoing = true;
|
||||||
await system.loadedPlugs.get("index")!.invoke("reindexSpace", []);
|
await system.invokeFunction("index.reindexSpace", []);
|
||||||
console.info("Full space index complete.");
|
console.info("Full space index complete.");
|
||||||
await markFullSpaceIndexComplete(ds);
|
await markFullSpaceIndexComplete(ds);
|
||||||
indexOngoing = false;
|
indexOngoing = false;
|
||||||
|
@ -40,6 +40,15 @@ export function handlebarHelpers() {
|
|||||||
nextWeek.setDate(nextWeek.getDate() + 7);
|
nextWeek.setDate(nextWeek.getDate() + 7);
|
||||||
return niceDate(nextWeek);
|
return niceDate(nextWeek);
|
||||||
},
|
},
|
||||||
|
weekStart: (startOnMonday = true) => {
|
||||||
|
const d = new Date();
|
||||||
|
const day = d.getDay();
|
||||||
|
let diff = d.getDate() - day;
|
||||||
|
if (startOnMonday) {
|
||||||
|
diff += day == 0 ? -6 : 1;
|
||||||
|
}
|
||||||
|
return niceDate(new Date(d.setDate(diff)));
|
||||||
|
},
|
||||||
ifEq: function (v1: any, v2: any, options: any) {
|
ifEq: function (v1: any, v2: any, options: any) {
|
||||||
if (v1 === v2) {
|
if (v1 === v2) {
|
||||||
return options.fn(this);
|
return options.fn(this);
|
||||||
|
@ -10,6 +10,16 @@ export function handlebarsSyscalls(): SysCallMapping {
|
|||||||
obj: any,
|
obj: any,
|
||||||
globals: Record<string, any> = {},
|
globals: Record<string, any> = {},
|
||||||
): string => {
|
): string => {
|
||||||
|
return renderHandlebarsTemplate(template, obj, globals);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderHandlebarsTemplate(
|
||||||
|
template: string,
|
||||||
|
obj: any,
|
||||||
|
globals: Record<string, any>,
|
||||||
|
) {
|
||||||
const templateFn = Handlebars.compile(
|
const templateFn = Handlebars.compile(
|
||||||
template,
|
template,
|
||||||
{ noEscape: true },
|
{ noEscape: true },
|
||||||
@ -18,6 +28,4 @@ export function handlebarsSyscalls(): SysCallMapping {
|
|||||||
helpers: handlebarHelpers(),
|
helpers: handlebarHelpers(),
|
||||||
data: globals,
|
data: globals,
|
||||||
});
|
});
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
@ -1,8 +1,9 @@
|
|||||||
import { SETTINGS_TEMPLATE } from "./settings_template.ts";
|
import { SETTINGS_TEMPLATE } from "./PAGE_TEMPLATES.ts";
|
||||||
import { YAML } from "./deps.ts";
|
import { YAML } from "./deps.ts";
|
||||||
import { SpacePrimitives } from "./spaces/space_primitives.ts";
|
import { SpacePrimitives } from "./spaces/space_primitives.ts";
|
||||||
import { expandPropertyNames } from "$sb/lib/json.ts";
|
import { expandPropertyNames } from "$sb/lib/json.ts";
|
||||||
import type { BuiltinSettings } from "../web/types.ts";
|
import type { BuiltinSettings } from "../web/types.ts";
|
||||||
|
import { INDEX_TEMPLATE } from "./PAGE_TEMPLATES.ts";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Runs a function safely by catching any errors and logging them to the console.
|
* Runs a function safely by catching any errors and logging them to the console.
|
||||||
@ -88,14 +89,7 @@ export async function ensureSettingsAndIndex(
|
|||||||
);
|
);
|
||||||
await space.writeFile(
|
await space.writeFile(
|
||||||
"index.md",
|
"index.md",
|
||||||
new TextEncoder().encode(
|
new TextEncoder().encode(INDEX_TEMPLATE),
|
||||||
`Hello! And welcome to your brand new SilverBullet space!
|
|
||||||
|
|
||||||
\`\`\`template
|
|
||||||
page: "[[!silverbullet.md/Getting Started]]"
|
|
||||||
\`\`\`
|
|
||||||
`,
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -19,6 +19,7 @@
|
|||||||
|
|
||||||
"preact": "https://esm.sh/preact@10.11.1",
|
"preact": "https://esm.sh/preact@10.11.1",
|
||||||
"$sb/": "./plug-api/",
|
"$sb/": "./plug-api/",
|
||||||
"handlebars": "https://esm.sh/handlebars@4.7.7?target=es2022"
|
"handlebars": "https://esm.sh/handlebars@4.7.7?target=es2022",
|
||||||
|
"zod": "https://deno.land/x/zod@v3.22.4/mod.ts"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -11,6 +11,12 @@ export const builtinFunctions: FunctionMap = {
|
|||||||
min(...args: number[]) {
|
min(...args: number[]) {
|
||||||
return Math.min(...args);
|
return Math.min(...args);
|
||||||
},
|
},
|
||||||
|
replace(str: string, match: [string, string] | string, replace: string) {
|
||||||
|
const matcher = Array.isArray(match)
|
||||||
|
? new RegExp(match[0], match[1] + "g")
|
||||||
|
: match;
|
||||||
|
return str.replaceAll(matcher, replace);
|
||||||
|
},
|
||||||
toJSON(obj: any) {
|
toJSON(obj: any) {
|
||||||
return JSON.stringify(obj);
|
return JSON.stringify(obj);
|
||||||
},
|
},
|
||||||
|
@ -191,7 +191,10 @@ export function nodeAtPos(tree: ParseTree, pos: number): ParseTree | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Turn ParseTree back into text
|
// Turn ParseTree back into text
|
||||||
export function renderToText(tree: ParseTree): string {
|
export function renderToText(tree?: ParseTree): string {
|
||||||
|
if (!tree) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
const pieces: string[] = [];
|
const pieces: string[] = [];
|
||||||
if (tree.text !== undefined) {
|
if (tree.text !== undefined) {
|
||||||
return tree.text;
|
return tree.text;
|
||||||
|
@ -39,6 +39,16 @@ export function navigate(
|
|||||||
return syscall("editor.navigate", name, pos, replaceState, newWindow);
|
return syscall("editor.navigate", name, pos, replaceState, newWindow);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function openPageNavigator(
|
||||||
|
mode: "page" | "template" = "page",
|
||||||
|
): Promise<void> {
|
||||||
|
return syscall("editor.openPageNavigator", mode);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function openCommandPalette(): Promise<void> {
|
||||||
|
return syscall("editor.openCommandPalette");
|
||||||
|
}
|
||||||
|
|
||||||
export function reloadPage(): Promise<void> {
|
export function reloadPage(): Promise<void> {
|
||||||
return syscall("editor.reloadPage");
|
return syscall("editor.reloadPage");
|
||||||
}
|
}
|
||||||
@ -47,6 +57,10 @@ export function reloadUI(): Promise<void> {
|
|||||||
return syscall("editor.reloadUI");
|
return syscall("editor.reloadUI");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function reloadSettingsAndCommands(): Promise<void> {
|
||||||
|
return syscall("editor.reloadSettingsAndCommands");
|
||||||
|
}
|
||||||
|
|
||||||
export function openUrl(url: string, existingWindow = false): Promise<void> {
|
export function openUrl(url: string, existingWindow = false): Promise<void> {
|
||||||
return syscall("editor.openUrl", url, existingWindow);
|
return syscall("editor.openUrl", url, existingWindow);
|
||||||
}
|
}
|
||||||
|
@ -75,6 +75,24 @@ export class System<HookT> extends EventEmitter<SystemEvents<HookT>> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Invokes a function named using the "plug.functionName" pattern, for convenience
|
||||||
|
* @param name name of the function (e.g. plug.doSomething)
|
||||||
|
* @param args an array of arguments to pass to the function
|
||||||
|
*/
|
||||||
|
invokeFunction(name: string, args: any[]): Promise<any> {
|
||||||
|
const [plugName, functionName] = name.split(".");
|
||||||
|
if (!functionName) {
|
||||||
|
// Sanity check
|
||||||
|
throw new Error(`Missing function name: ${name}`);
|
||||||
|
}
|
||||||
|
const plug = this.loadedPlugs.get(plugName);
|
||||||
|
if (!plug) {
|
||||||
|
throw new Error(`Plug ${plugName} not found invoking ${name}`);
|
||||||
|
}
|
||||||
|
return plug.invoke(functionName, args);
|
||||||
|
}
|
||||||
|
|
||||||
localSyscall(name: string, args: any): Promise<any> {
|
localSyscall(name: string, args: any): Promise<any> {
|
||||||
return this.syscall({}, name, args);
|
return this.syscall({}, name, args);
|
||||||
}
|
}
|
||||||
@ -90,7 +108,7 @@ export class System<HookT> extends EventEmitter<SystemEvents<HookT>> {
|
|||||||
}
|
}
|
||||||
if (ctx.plug) {
|
if (ctx.plug) {
|
||||||
// Only when running in a plug context do we check permissions
|
// Only when running in a plug context do we check permissions
|
||||||
const plug = this.loadedPlugs.get(ctx.plug!);
|
const plug = this.loadedPlugs.get(ctx.plug);
|
||||||
if (!plug) {
|
if (!plug) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Plug ${ctx.plug} not found while attempting to invoke ${name}}`,
|
`Plug ${ctx.plug} not found while attempting to invoke ${name}}`,
|
||||||
|
@ -17,8 +17,14 @@ export async function pageComplete(completeEvent: CompleteEvent) {
|
|||||||
/render\s+\[\[|page:\s*["']\[\[/.test(
|
/render\s+\[\[|page:\s*["']\[\[/.test(
|
||||||
completeEvent.linePrefix,
|
completeEvent.linePrefix,
|
||||||
);
|
);
|
||||||
const tagToQuery = isInTemplateContext ? "template" : "page";
|
|
||||||
let allPages: PageMeta[] = await queryObjects<PageMeta>(tagToQuery, {}, 5);
|
// When in a template context, we only want to complete template pages
|
||||||
|
// When outside of a template context, we want to complete all pages except template pages
|
||||||
|
let allPages: PageMeta[] = isInTemplateContext
|
||||||
|
? await queryObjects<PageMeta>("template", {}, 5)
|
||||||
|
: await queryObjects<PageMeta>("page", {
|
||||||
|
filter: ["!=", ["attr", "tags"], ["string", "template"]],
|
||||||
|
}, 5);
|
||||||
const prefix = match[1];
|
const prefix = match[1];
|
||||||
if (prefix.startsWith("!")) {
|
if (prefix.startsWith("!")) {
|
||||||
// Federation prefix, let's first see if we're matching anything from federation that is locally synced
|
// Federation prefix, let's first see if we're matching anything from federation that is locally synced
|
||||||
|
@ -15,6 +15,27 @@ functions:
|
|||||||
command:
|
command:
|
||||||
name: "Editor: Toggle Dark Mode"
|
name: "Editor: Toggle Dark Mode"
|
||||||
|
|
||||||
|
openCommandPalette:
|
||||||
|
path: editor.ts:openCommandPalette
|
||||||
|
command:
|
||||||
|
name: "Open Command Palette"
|
||||||
|
key: "Ctrl-/"
|
||||||
|
mac: "Cmd-/"
|
||||||
|
|
||||||
|
openPageNavigator:
|
||||||
|
path: editor.ts:openPageNavigator
|
||||||
|
command:
|
||||||
|
name: "Open Page Navigator"
|
||||||
|
key: "Ctrl-k"
|
||||||
|
mac: "Cmd-k"
|
||||||
|
|
||||||
|
openTemplateNavigator:
|
||||||
|
path: editor.ts:openTemplateNavigator
|
||||||
|
command:
|
||||||
|
name: "Open Template Navigator"
|
||||||
|
key: "Ctrl-Shift-t"
|
||||||
|
mac: "Cmd-Shift-t"
|
||||||
|
|
||||||
# Page operations
|
# Page operations
|
||||||
deletePage:
|
deletePage:
|
||||||
path: "./page.ts:deletePage"
|
path: "./page.ts:deletePage"
|
||||||
@ -35,6 +56,11 @@ functions:
|
|||||||
events:
|
events:
|
||||||
- editor:complete
|
- editor:complete
|
||||||
|
|
||||||
|
reloadSettingsAndCommands:
|
||||||
|
path: editor.ts:reloadSettingsAndCommands
|
||||||
|
command:
|
||||||
|
name: "System: Reload Settings and Commands"
|
||||||
|
|
||||||
# Navigation
|
# Navigation
|
||||||
linkNavigate:
|
linkNavigate:
|
||||||
path: "./navigate.ts:linkNavigate"
|
path: "./navigate.ts:linkNavigate"
|
||||||
|
@ -10,6 +10,18 @@ export async function setEditorMode() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function openCommandPalette() {
|
||||||
|
return editor.openCommandPalette();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function openPageNavigator() {
|
||||||
|
await editor.openPageNavigator("page");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function openTemplateNavigator() {
|
||||||
|
await editor.openPageNavigator("template");
|
||||||
|
}
|
||||||
|
|
||||||
export async function toggleDarkMode() {
|
export async function toggleDarkMode() {
|
||||||
let darkMode = await clientStore.get("darkMode");
|
let darkMode = await clientStore.get("darkMode");
|
||||||
darkMode = !darkMode;
|
darkMode = !darkMode;
|
||||||
@ -34,3 +46,8 @@ export async function moveToPosCommand() {
|
|||||||
export async function customFlashMessage(_def: any, message: string) {
|
export async function customFlashMessage(_def: any, message: string) {
|
||||||
await editor.flashNotification(message);
|
await editor.flashNotification(message);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function reloadSettingsAndCommands() {
|
||||||
|
await editor.reloadSettingsAndCommands();
|
||||||
|
await editor.flashNotification("Reloaded settings and commands");
|
||||||
|
}
|
||||||
|
@ -14,15 +14,20 @@ export async function deletePage() {
|
|||||||
await space.deletePage(pageName);
|
await space.deletePage(pageName);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function copyPage(_def: any, predefinedNewName: string) {
|
export async function copyPage(
|
||||||
const oldName = await editor.getCurrentPage();
|
_def: any,
|
||||||
let suggestedName = predefinedNewName || oldName;
|
sourcePage?: string,
|
||||||
|
toName?: string,
|
||||||
|
) {
|
||||||
|
const currentPage = await editor.getCurrentPage();
|
||||||
|
const fromName = sourcePage || currentPage;
|
||||||
|
let suggestedName = toName || fromName;
|
||||||
|
|
||||||
if (isFederationPath(oldName)) {
|
if (isFederationPath(fromName)) {
|
||||||
const pieces = oldName.split("/");
|
const pieces = fromName.split("/");
|
||||||
suggestedName = pieces.slice(1).join("/");
|
suggestedName = pieces.slice(1).join("/");
|
||||||
}
|
}
|
||||||
const newName = await editor.prompt(`Copy to new page:`, suggestedName);
|
const newName = await editor.prompt(`Copy to page:`, suggestedName);
|
||||||
|
|
||||||
if (!newName) {
|
if (!newName) {
|
||||||
return;
|
return;
|
||||||
@ -44,11 +49,17 @@ export async function copyPage(_def: any, predefinedNewName: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const text = await editor.getText();
|
const text = await space.readPage(fromName);
|
||||||
|
|
||||||
console.log("Writing new page to space");
|
console.log("Writing new page to space");
|
||||||
await space.writePage(newName, text);
|
await space.writePage(newName, text);
|
||||||
|
|
||||||
|
if (currentPage === fromName) {
|
||||||
|
// If we're copying the current page, navigate there
|
||||||
console.log("Navigating to new page");
|
console.log("Navigating to new page");
|
||||||
await editor.navigate(newName);
|
await editor.navigate(newName);
|
||||||
|
} else {
|
||||||
|
// Otherwise just notify of success
|
||||||
|
await editor.flashNotification("Page copied successfully");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -28,3 +28,9 @@ functions:
|
|||||||
pageNamespace:
|
pageNamespace:
|
||||||
pattern: "!.+"
|
pattern: "!.+"
|
||||||
operation: getFileMeta
|
operation: getFileMeta
|
||||||
|
|
||||||
|
# Library management
|
||||||
|
importLibraryCommand:
|
||||||
|
path: library.ts:importLibraryCommand
|
||||||
|
command:
|
||||||
|
name: "Library: Import"
|
@ -117,7 +117,7 @@ export async function cacheFileListing(uri: string): Promise<FileMeta[]> {
|
|||||||
|
|
||||||
export async function readFile(
|
export async function readFile(
|
||||||
name: string,
|
name: string,
|
||||||
): Promise<{ data: Uint8Array; meta: FileMeta } | undefined> {
|
): Promise<{ data: Uint8Array; meta: FileMeta }> {
|
||||||
const url = federatedPathToUrl(name);
|
const url = federatedPathToUrl(name);
|
||||||
console.log("Fetching federated file", url);
|
console.log("Fetching federated file", url);
|
||||||
const r = await nativeFetch(url, {
|
const r = await nativeFetch(url, {
|
||||||
|
55
plugs/federation/library.ts
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
import { editor, space } from "$sb/syscalls.ts";
|
||||||
|
import { cacheFileListing, readFile } from "./federation.ts";
|
||||||
|
|
||||||
|
export async function importLibraryCommand(_def: any, uri?: string) {
|
||||||
|
if (!uri) {
|
||||||
|
uri = await editor.prompt("Import library (federation URL):");
|
||||||
|
}
|
||||||
|
if (!uri) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
uri = uri.trim();
|
||||||
|
if (!uri.startsWith("!")) {
|
||||||
|
uri = `!${uri}`;
|
||||||
|
}
|
||||||
|
const allTemplates = (await cacheFileListing(uri)).filter((f) =>
|
||||||
|
f.name.endsWith(".md")
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
!await editor.confirm(
|
||||||
|
`You are about to import ${allTemplates.length} templates, want to do this?`,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const template of allTemplates) {
|
||||||
|
// Clean up file path
|
||||||
|
let pageName = template.name.replace(/\.md$/, "");
|
||||||
|
// Remove the federation part
|
||||||
|
const pieces = pageName.split("/");
|
||||||
|
pageName = pieces.slice(1).join("/");
|
||||||
|
|
||||||
|
// Fetch the file
|
||||||
|
const buf = (await readFile(template.name)).data;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Check if it already exists
|
||||||
|
await space.getPageMeta(pageName);
|
||||||
|
|
||||||
|
if (
|
||||||
|
!await editor.confirm(
|
||||||
|
`Page ${pageName} already exists, are you sure you want to override it?`,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Expected
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write to local space
|
||||||
|
await space.writePage(pageName, new TextDecoder().decode(buf));
|
||||||
|
}
|
||||||
|
await editor.reloadSettingsAndCommands();
|
||||||
|
await editor.flashNotification("Import complete!");
|
||||||
|
}
|
@ -60,7 +60,7 @@ export async function clearIndex(): Promise<void> {
|
|||||||
console.log("Deleted", allKeys.length, "keys from the index");
|
console.log("Deleted", allKeys.length, "keys from the index");
|
||||||
}
|
}
|
||||||
|
|
||||||
// ENTITIES API
|
// OBJECTS API
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Indexes entities in the data store
|
* Indexes entities in the data store
|
||||||
|
@ -80,11 +80,7 @@ export const builtins: Record<string, Record<string, string>> = {
|
|||||||
page: "!string",
|
page: "!string",
|
||||||
pageName: "string",
|
pageName: "string",
|
||||||
pos: "!number",
|
pos: "!number",
|
||||||
type: "string",
|
hooks: "hooksSpec",
|
||||||
trigger: "string",
|
|
||||||
where: "string",
|
|
||||||
priority: "number",
|
|
||||||
enabled: "boolean",
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -162,17 +162,17 @@ functions:
|
|||||||
|
|
||||||
# Template Widgets
|
# Template Widgets
|
||||||
renderTemplateWidgetsTop:
|
renderTemplateWidgetsTop:
|
||||||
path: template_widget.ts:renderTemplateWidgets
|
path: widget.ts:renderTemplateWidgets
|
||||||
env: client
|
env: client
|
||||||
panelWidget: top
|
panelWidget: top
|
||||||
|
|
||||||
renderTemplateWidgetsBottom:
|
renderTemplateWidgetsBottom:
|
||||||
path: template_widget.ts:renderTemplateWidgets
|
path: widget.ts:renderTemplateWidgets
|
||||||
env: client
|
env: client
|
||||||
panelWidget: bottom
|
panelWidget: bottom
|
||||||
|
|
||||||
refreshWidgets:
|
refreshWidgets:
|
||||||
path: template_widget.ts:refreshWidgets
|
path: widget.ts:refreshWidgets
|
||||||
|
|
||||||
lintYAML:
|
lintYAML:
|
||||||
path: lint.ts:lintYAML
|
path: lint.ts:lintYAML
|
||||||
|
@ -41,6 +41,10 @@ export async function widget(
|
|||||||
return false;
|
return false;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (headers.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
if (config.minHeaders && headers.length < config.minHeaders) {
|
if (config.minHeaders && headers.length < config.minHeaders) {
|
||||||
// Not enough headers, not showing TOC
|
// Not enough headers, not showing TOC
|
||||||
return null;
|
return null;
|
||||||
|
@ -7,9 +7,9 @@ import {
|
|||||||
} from "$sb/silverbullet-syscall/mod.ts";
|
} from "$sb/silverbullet-syscall/mod.ts";
|
||||||
import { parseTreeToAST, renderToText } from "$sb/lib/tree.ts";
|
import { parseTreeToAST, renderToText } from "$sb/lib/tree.ts";
|
||||||
import { CodeWidgetContent } from "$sb/types.ts";
|
import { CodeWidgetContent } from "$sb/types.ts";
|
||||||
import { loadPageObject } from "../template/template.ts";
|
import { loadPageObject } from "../template/page.ts";
|
||||||
import { queryObjects } from "./api.ts";
|
import { queryObjects } from "./api.ts";
|
||||||
import { TemplateObject } from "../template/types.ts";
|
import { TemplateObject, WidgetConfig } from "../template/types.ts";
|
||||||
import { expressionToKvQueryExpression } from "$sb/lib/parse-query.ts";
|
import { expressionToKvQueryExpression } from "$sb/lib/parse-query.ts";
|
||||||
import { evalQueryExpression } from "$sb/lib/query.ts";
|
import { evalQueryExpression } from "$sb/lib/query.ts";
|
||||||
import { renderTemplate } from "../template/plug_api.ts";
|
import { renderTemplate } from "../template/plug_api.ts";
|
||||||
@ -24,35 +24,39 @@ export async function renderTemplateWidgets(side: "top" | "bottom"): Promise<
|
|||||||
CodeWidgetContent | null
|
CodeWidgetContent | null
|
||||||
> {
|
> {
|
||||||
const text = await editor.getText();
|
const text = await editor.getText();
|
||||||
const pageMeta = await loadPageObject(await editor.getCurrentPage());
|
let pageMeta = await loadPageObject(await editor.getCurrentPage());
|
||||||
const parsedMd = await markdown.parseMarkdown(text);
|
const parsedMd = await markdown.parseMarkdown(text);
|
||||||
const frontmatter = await extractFrontmatter(parsedMd);
|
const frontmatter = await extractFrontmatter(parsedMd);
|
||||||
|
|
||||||
const allFrontMatterTemplates = await queryObjects<TemplateObject>(
|
pageMeta = { ...pageMeta, ...frontmatter };
|
||||||
|
|
||||||
|
const blockTemplates = await queryObjects<TemplateObject>(
|
||||||
"template",
|
"template",
|
||||||
{
|
{
|
||||||
// where type = "widget:X" and enabled != false
|
// where hooks.top/bottom exists
|
||||||
filter: ["and", ["=", ["attr", "type"], ["string", `widget:${side}`]], [
|
filter: ["attr", ["attr", "hooks"], side],
|
||||||
"!=",
|
orderBy: [{
|
||||||
["attr", "enabled"],
|
// order by hooks.top/bottom.order asc
|
||||||
["boolean", false],
|
expr: ["attr", ["attr", ["attr", "hooks"], side], "order"],
|
||||||
]],
|
desc: false,
|
||||||
orderBy: [{ expr: ["attr", "priority"], desc: false }],
|
}],
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
// console.log(`Found the following ${side} templates`, blockTemplates);
|
||||||
const templateBits: string[] = [];
|
const templateBits: string[] = [];
|
||||||
// Strategy: walk through all matching templates, evaluate the 'where' expression, and pick the first one that matches
|
// Strategy: walk through all matching templates, evaluate the 'where' expression, and pick the first one that matches
|
||||||
for (const template of allFrontMatterTemplates) {
|
for (const template of blockTemplates) {
|
||||||
if (!template.where) {
|
if (!template.hooks) {
|
||||||
console.warn(
|
console.warn(
|
||||||
"Skipping template",
|
"No hooks specified for template",
|
||||||
template.ref,
|
template.ref,
|
||||||
"because it has no 'where' expression",
|
"this should never happen",
|
||||||
);
|
);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
const blockDef = WidgetConfig.parse(template.hooks[side]!);
|
||||||
const exprAST = parseTreeToAST(
|
const exprAST = parseTreeToAST(
|
||||||
await language.parseLanguage("expression", template.where!),
|
await language.parseLanguage("expression", blockDef.where!),
|
||||||
);
|
);
|
||||||
const parsedExpression = expressionToKvQueryExpression(exprAST[1]);
|
const parsedExpression = expressionToKvQueryExpression(exprAST[1]);
|
||||||
if (evalQueryExpression(parsedExpression, pageMeta)) {
|
if (evalQueryExpression(parsedExpression, pageMeta)) {
|
||||||
@ -68,11 +72,11 @@ export async function renderTemplateWidgets(side: "top" | "bottom"): Promise<
|
|||||||
rewritePageRefs(parsedMarkdown, template.ref);
|
rewritePageRefs(parsedMarkdown, template.ref);
|
||||||
renderedTemplate = renderToText(parsedMarkdown);
|
renderedTemplate = renderToText(parsedMarkdown);
|
||||||
|
|
||||||
|
// console.log("Rendering template", template.ref, renderedTemplate);
|
||||||
templateBits.push(renderedTemplate.trim());
|
templateBits.push(renderedTemplate.trim());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const summaryText = templateBits.join("\n");
|
const summaryText = templateBits.join("\n");
|
||||||
// console.log("Rendered", summaryText);
|
|
||||||
return {
|
return {
|
||||||
markdown: summaryText,
|
markdown: summaryText,
|
||||||
buttons: [
|
buttons: [
|
@ -54,6 +54,7 @@ export async function expandCodeWidgets(
|
|||||||
// 'not found' is to be expected (no code widget configured for this language)
|
// 'not found' is to be expected (no code widget configured for this language)
|
||||||
// Every other error should probably be reported
|
// Every other error should probably be reported
|
||||||
if (!e.message.includes("not found")) {
|
if (!e.message.includes("not found")) {
|
||||||
|
console.trace();
|
||||||
console.error("Error rendering code", e.message);
|
console.error("Error rendering code", e.message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -330,6 +330,13 @@ function render(
|
|||||||
const command = t.children![1].children![0].text!;
|
const command = t.children![1].children![0].text!;
|
||||||
let commandText = command;
|
let commandText = command;
|
||||||
const aliasNode = findNodeOfType(t, "CommandLinkAlias");
|
const aliasNode = findNodeOfType(t, "CommandLinkAlias");
|
||||||
|
const argsNode = findNodeOfType(t, "CommandLinkArgs");
|
||||||
|
let args: any = [];
|
||||||
|
|
||||||
|
if (argsNode) {
|
||||||
|
args = JSON.parse(`[${argsNode.children![0].text!}]`);
|
||||||
|
}
|
||||||
|
|
||||||
if (aliasNode) {
|
if (aliasNode) {
|
||||||
commandText = aliasNode.children![0].text!;
|
commandText = aliasNode.children![0].text!;
|
||||||
}
|
}
|
||||||
@ -337,7 +344,7 @@ function render(
|
|||||||
return {
|
return {
|
||||||
name: "button",
|
name: "button",
|
||||||
attrs: {
|
attrs: {
|
||||||
"data-onclick": JSON.stringify(["command", command]),
|
"data-onclick": JSON.stringify(["command", command, args]),
|
||||||
},
|
},
|
||||||
body: commandText,
|
body: commandText,
|
||||||
};
|
};
|
||||||
|
91
plugs/query/lint.ts
Normal file
@ -0,0 +1,91 @@
|
|||||||
|
import { LintEvent } from "$sb/app_event.ts";
|
||||||
|
import { parseQuery } from "$sb/lib/parse-query.ts";
|
||||||
|
import { cleanPageRef, resolvePath } from "$sb/lib/resolve.ts";
|
||||||
|
import { findNodeOfType, traverseTreeAsync } from "$sb/lib/tree.ts";
|
||||||
|
import { events, space } from "$sb/syscalls.ts";
|
||||||
|
import { LintDiagnostic } from "$sb/types.ts";
|
||||||
|
import { loadPageObject, replaceTemplateVars } from "../template/page.ts";
|
||||||
|
|
||||||
|
export async function lintQuery(
|
||||||
|
{ name, tree }: LintEvent,
|
||||||
|
): Promise<LintDiagnostic[]> {
|
||||||
|
const diagnostics: LintDiagnostic[] = [];
|
||||||
|
await traverseTreeAsync(tree, async (node) => {
|
||||||
|
if (node.type === "FencedCode") {
|
||||||
|
const codeInfo = findNodeOfType(node, "CodeInfo")!;
|
||||||
|
if (!codeInfo) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const codeLang = codeInfo.children![0].text!;
|
||||||
|
if (
|
||||||
|
codeLang !== "query"
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const codeText = findNodeOfType(node, "CodeText");
|
||||||
|
if (!codeText) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const bodyText = codeText.children![0].text!;
|
||||||
|
try {
|
||||||
|
const pageObject = await loadPageObject(name);
|
||||||
|
const parsedQuery = await parseQuery(
|
||||||
|
await replaceTemplateVars(bodyText, pageObject),
|
||||||
|
);
|
||||||
|
|
||||||
|
const allSources = await allQuerySources();
|
||||||
|
if (
|
||||||
|
parsedQuery.querySource &&
|
||||||
|
!allSources.includes(parsedQuery.querySource)
|
||||||
|
) {
|
||||||
|
diagnostics.push({
|
||||||
|
from: codeText.from!,
|
||||||
|
to: codeText.to!,
|
||||||
|
message: `Unknown query source '${parsedQuery.querySource}'`,
|
||||||
|
severity: "error",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (parsedQuery.render) {
|
||||||
|
const templatePage = resolvePath(
|
||||||
|
name,
|
||||||
|
cleanPageRef(parsedQuery.render),
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
await space.getPageMeta(templatePage);
|
||||||
|
} catch {
|
||||||
|
diagnostics.push({
|
||||||
|
from: codeText.from!,
|
||||||
|
to: codeText.to!,
|
||||||
|
message: `Could not resolve template ${templatePage}`,
|
||||||
|
severity: "error",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
diagnostics.push({
|
||||||
|
from: codeText.from!,
|
||||||
|
to: codeText.to!,
|
||||||
|
message: e.message,
|
||||||
|
severity: "error",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
return diagnostics;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function allQuerySources(): Promise<string[]> {
|
||||||
|
const allEvents = await events.listEvents();
|
||||||
|
|
||||||
|
const allSources = allEvents
|
||||||
|
.filter((eventName) =>
|
||||||
|
eventName.startsWith("query:") && !eventName.includes("*")
|
||||||
|
)
|
||||||
|
.map((source) => source.substring("query:".length));
|
||||||
|
|
||||||
|
const allObjectTypes: string[] = (await events.dispatchEvent("query_", {}))
|
||||||
|
.flat();
|
||||||
|
|
||||||
|
return [...allSources, ...allObjectTypes];
|
||||||
|
}
|
@ -1,20 +1,19 @@
|
|||||||
name: query
|
name: query
|
||||||
functions:
|
functions:
|
||||||
queryWidget:
|
queryWidget:
|
||||||
path: query.ts:widget
|
path: widget.ts:widget
|
||||||
codeWidget: query
|
codeWidget: query
|
||||||
renderMode: markdown
|
renderMode: markdown
|
||||||
|
|
||||||
|
# Query widget buttons
|
||||||
|
editButton:
|
||||||
|
path: widget.ts:editButton
|
||||||
|
|
||||||
lintQuery:
|
lintQuery:
|
||||||
path: query.ts:lintQuery
|
path: lint.ts:lintQuery
|
||||||
events:
|
events:
|
||||||
- editor:lint
|
- editor:lint
|
||||||
|
|
||||||
templateWidget:
|
|
||||||
path: template.ts:widget
|
|
||||||
codeWidget: template
|
|
||||||
renderMode: markdown
|
|
||||||
|
|
||||||
queryComplete:
|
queryComplete:
|
||||||
path: complete.ts:queryComplete
|
path: complete.ts:queryComplete
|
||||||
events:
|
events:
|
||||||
@ -31,26 +30,3 @@ functions:
|
|||||||
name: "Live Queries and Templates: Refresh All"
|
name: "Live Queries and Templates: Refresh All"
|
||||||
key: "Alt-q"
|
key: "Alt-q"
|
||||||
|
|
||||||
# Query widget buttons
|
|
||||||
editButton:
|
|
||||||
path: widget.ts:editButton
|
|
||||||
|
|
||||||
# Slash commands
|
|
||||||
insertQuery:
|
|
||||||
redirect: template.insertTemplateText
|
|
||||||
slashCommand:
|
|
||||||
name: query
|
|
||||||
description: Insert a query
|
|
||||||
value: |
|
|
||||||
```query
|
|
||||||
|^|
|
|
||||||
```
|
|
||||||
insertUseTemplate:
|
|
||||||
redirect: template.insertTemplateText
|
|
||||||
slashCommand:
|
|
||||||
name: template
|
|
||||||
description: Use a template
|
|
||||||
value: |
|
|
||||||
```template
|
|
||||||
page: "[[|^|]]"
|
|
||||||
```
|
|
||||||
|
@ -1,173 +0,0 @@
|
|||||||
import type { LintEvent } from "$sb/app_event.ts";
|
|
||||||
import { events, space } from "$sb/syscalls.ts";
|
|
||||||
import { findNodeOfType, traverseTreeAsync } from "$sb/lib/tree.ts";
|
|
||||||
import { parseQuery } from "$sb/lib/parse-query.ts";
|
|
||||||
import { loadPageObject, replaceTemplateVars } from "../template/template.ts";
|
|
||||||
import { cleanPageRef, resolvePath } from "$sb/lib/resolve.ts";
|
|
||||||
import {
|
|
||||||
CodeWidgetContent,
|
|
||||||
LintDiagnostic,
|
|
||||||
PageMeta,
|
|
||||||
Query,
|
|
||||||
} from "$sb/types.ts";
|
|
||||||
import { jsonToMDTable, renderQueryTemplate } from "../template/util.ts";
|
|
||||||
|
|
||||||
export async function widget(
|
|
||||||
bodyText: string,
|
|
||||||
pageName: string,
|
|
||||||
): Promise<CodeWidgetContent> {
|
|
||||||
const pageObject = await loadPageObject(pageName);
|
|
||||||
try {
|
|
||||||
let resultMarkdown = "";
|
|
||||||
const parsedQuery = await parseQuery(
|
|
||||||
await replaceTemplateVars(bodyText, pageObject),
|
|
||||||
);
|
|
||||||
|
|
||||||
const results = await performQuery(
|
|
||||||
parsedQuery,
|
|
||||||
pageObject,
|
|
||||||
);
|
|
||||||
if (results.length === 0 && !parsedQuery.renderAll) {
|
|
||||||
resultMarkdown = "No results";
|
|
||||||
} else {
|
|
||||||
if (parsedQuery.render) {
|
|
||||||
// Configured a custom rendering template, let's use it!
|
|
||||||
const templatePage = resolvePath(pageName, parsedQuery.render);
|
|
||||||
const rendered = await renderQueryTemplate(
|
|
||||||
pageObject,
|
|
||||||
templatePage,
|
|
||||||
results,
|
|
||||||
parsedQuery.renderAll!,
|
|
||||||
);
|
|
||||||
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(results);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
markdown: resultMarkdown,
|
|
||||||
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}` };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function performQuery(parsedQuery: Query, pageObject: PageMeta) {
|
|
||||||
if (!parsedQuery.limit) {
|
|
||||||
parsedQuery.limit = ["number", 1000];
|
|
||||||
}
|
|
||||||
|
|
||||||
const eventName = `query:${parsedQuery.querySource}`;
|
|
||||||
// console.log("Parsed query", parsedQuery);
|
|
||||||
// Let's dispatch an event and see what happens
|
|
||||||
const results = await events.dispatchEvent(
|
|
||||||
eventName,
|
|
||||||
{ query: parsedQuery, pageName: pageObject.name },
|
|
||||||
30 * 1000,
|
|
||||||
);
|
|
||||||
if (results.length === 0) {
|
|
||||||
throw new Error(`Unsupported query source '${parsedQuery.querySource}'`);
|
|
||||||
}
|
|
||||||
return results.flat();
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function lintQuery(
|
|
||||||
{ name, tree }: LintEvent,
|
|
||||||
): Promise<LintDiagnostic[]> {
|
|
||||||
const diagnostics: LintDiagnostic[] = [];
|
|
||||||
await traverseTreeAsync(tree, async (node) => {
|
|
||||||
if (node.type === "FencedCode") {
|
|
||||||
const codeInfo = findNodeOfType(node, "CodeInfo")!;
|
|
||||||
if (!codeInfo) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
const codeLang = codeInfo.children![0].text!;
|
|
||||||
if (
|
|
||||||
codeLang !== "query"
|
|
||||||
) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
const codeText = findNodeOfType(node, "CodeText");
|
|
||||||
if (!codeText) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
const bodyText = codeText.children![0].text!;
|
|
||||||
try {
|
|
||||||
const pageObject = await loadPageObject(name);
|
|
||||||
const parsedQuery = await parseQuery(
|
|
||||||
await replaceTemplateVars(bodyText, pageObject),
|
|
||||||
);
|
|
||||||
|
|
||||||
const allSources = await allQuerySources();
|
|
||||||
if (
|
|
||||||
parsedQuery.querySource &&
|
|
||||||
!allSources.includes(parsedQuery.querySource)
|
|
||||||
) {
|
|
||||||
diagnostics.push({
|
|
||||||
from: codeText.from!,
|
|
||||||
to: codeText.to!,
|
|
||||||
message: `Unknown query source '${parsedQuery.querySource}'`,
|
|
||||||
severity: "error",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (parsedQuery.render) {
|
|
||||||
const templatePage = resolvePath(
|
|
||||||
name,
|
|
||||||
cleanPageRef(parsedQuery.render),
|
|
||||||
);
|
|
||||||
try {
|
|
||||||
await space.getPageMeta(templatePage);
|
|
||||||
} catch {
|
|
||||||
diagnostics.push({
|
|
||||||
from: codeText.from!,
|
|
||||||
to: codeText.to!,
|
|
||||||
message: `Could not resolve template ${templatePage}`,
|
|
||||||
severity: "error",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e: any) {
|
|
||||||
diagnostics.push({
|
|
||||||
from: codeText.from!,
|
|
||||||
to: codeText.to!,
|
|
||||||
message: e.message,
|
|
||||||
severity: "error",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
});
|
|
||||||
return diagnostics;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function allQuerySources(): Promise<string[]> {
|
|
||||||
const allEvents = await events.listEvents();
|
|
||||||
|
|
||||||
const allSources = allEvents
|
|
||||||
.filter((eventName) =>
|
|
||||||
eventName.startsWith("query:") && !eventName.includes("*")
|
|
||||||
)
|
|
||||||
.map((source) => source.substring("query:".length));
|
|
||||||
|
|
||||||
const allObjectTypes: string[] = (await events.dispatchEvent("query_", {}))
|
|
||||||
.flat();
|
|
||||||
|
|
||||||
return [...allSources, ...allObjectTypes];
|
|
||||||
}
|
|
@ -1,4 +1,85 @@
|
|||||||
import { codeWidget, editor } from "$sb/syscalls.ts";
|
import { codeWidget, editor, events } from "$sb/syscalls.ts";
|
||||||
|
import { parseQuery } from "$sb/lib/parse-query.ts";
|
||||||
|
import { loadPageObject, replaceTemplateVars } from "../template/page.ts";
|
||||||
|
import { resolvePath } from "$sb/lib/resolve.ts";
|
||||||
|
import { CodeWidgetContent, PageMeta, Query } from "$sb/types.ts";
|
||||||
|
import { jsonToMDTable, renderQueryTemplate } from "../template/util.ts";
|
||||||
|
|
||||||
|
export async function widget(
|
||||||
|
bodyText: string,
|
||||||
|
pageName: string,
|
||||||
|
): Promise<CodeWidgetContent> {
|
||||||
|
const pageObject = await loadPageObject(pageName);
|
||||||
|
try {
|
||||||
|
let resultMarkdown = "";
|
||||||
|
const parsedQuery = await parseQuery(
|
||||||
|
await replaceTemplateVars(bodyText, pageObject),
|
||||||
|
);
|
||||||
|
|
||||||
|
const results = await performQuery(
|
||||||
|
parsedQuery,
|
||||||
|
pageObject,
|
||||||
|
);
|
||||||
|
if (results.length === 0 && !parsedQuery.renderAll) {
|
||||||
|
resultMarkdown = "No results";
|
||||||
|
} else {
|
||||||
|
if (parsedQuery.render) {
|
||||||
|
// Configured a custom rendering template, let's use it!
|
||||||
|
const templatePage = resolvePath(pageName, parsedQuery.render);
|
||||||
|
const rendered = await renderQueryTemplate(
|
||||||
|
pageObject,
|
||||||
|
templatePage,
|
||||||
|
results,
|
||||||
|
parsedQuery.renderAll!,
|
||||||
|
);
|
||||||
|
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(results);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
markdown: resultMarkdown,
|
||||||
|
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}` };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function performQuery(parsedQuery: Query, pageObject: PageMeta) {
|
||||||
|
if (!parsedQuery.limit) {
|
||||||
|
parsedQuery.limit = ["number", 1000];
|
||||||
|
}
|
||||||
|
|
||||||
|
const eventName = `query:${parsedQuery.querySource}`;
|
||||||
|
// console.log("Parsed query", parsedQuery);
|
||||||
|
// Let's dispatch an event and see what happens
|
||||||
|
const results = await events.dispatchEvent(
|
||||||
|
eventName,
|
||||||
|
{ query: parsedQuery, pageName: pageObject.name },
|
||||||
|
30 * 1000,
|
||||||
|
);
|
||||||
|
if (results.length === 0) {
|
||||||
|
throw new Error(`Unsupported query source '${parsedQuery.querySource}'`);
|
||||||
|
}
|
||||||
|
return results.flat();
|
||||||
|
}
|
||||||
|
|
||||||
export function refreshAllWidgets() {
|
export function refreshAllWidgets() {
|
||||||
codeWidget.refreshAll();
|
codeWidget.refreshAll();
|
||||||
|
@ -23,15 +23,6 @@ functions:
|
|||||||
updateTaskState:
|
updateTaskState:
|
||||||
path: task.ts:updateTaskState
|
path: task.ts:updateTaskState
|
||||||
|
|
||||||
|
|
||||||
turnIntoTask:
|
|
||||||
redirect: template.applyLineReplace
|
|
||||||
slashCommand:
|
|
||||||
name: task
|
|
||||||
description: Turn into task
|
|
||||||
match: "^(\\s*)[\\-\\*]?\\s*(\\[[ xX]\\])?\\s*"
|
|
||||||
replace: "$1* [ ] "
|
|
||||||
|
|
||||||
indexTasks:
|
indexTasks:
|
||||||
path: "./task.ts:indexTasks"
|
path: "./task.ts:indexTasks"
|
||||||
events:
|
events:
|
||||||
|
@ -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
plugs/template/lint.ts
Normal 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
plugs/template/page.ts
Normal 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
plugs/template/snippet.ts
Normal 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",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
@ -3,135 +3,51 @@ functions:
|
|||||||
# API
|
# API
|
||||||
renderTemplate:
|
renderTemplate:
|
||||||
path: api.ts:renderTemplate
|
path: api.ts:renderTemplate
|
||||||
|
|
||||||
cleanTemplate:
|
cleanTemplate:
|
||||||
path: api.ts:cleanTemplate
|
path: api.ts:cleanTemplate
|
||||||
|
|
||||||
# Used by various slash commands
|
# Indexing
|
||||||
insertTemplateText:
|
|
||||||
path: template.ts:insertTemplateText
|
|
||||||
|
|
||||||
indexTemplate:
|
indexTemplate:
|
||||||
path: ./index.ts:indexTemplate
|
path: index.ts:indexTemplate
|
||||||
events:
|
events:
|
||||||
# Special event only triggered for template pages
|
# Special event only triggered for template pages
|
||||||
- page:indexTemplate
|
- page:indexTemplate
|
||||||
|
|
||||||
# Completion
|
# Completion
|
||||||
templateSlashCommand:
|
templateSlashCommand:
|
||||||
path: ./complete.ts:templateSlashComplete
|
path: snippet.ts:snippetSlashComplete
|
||||||
events:
|
events:
|
||||||
- slash:complete
|
- slash:complete
|
||||||
|
|
||||||
insertSlashTemplate:
|
insertSnippetTemplate:
|
||||||
path: ./complete.ts:insertSlashTemplate
|
path: snippet.ts:insertSnippetTemplate
|
||||||
|
|
||||||
handlebarHelperComplete:
|
handlebarHelperComplete:
|
||||||
path: ./complete.ts:templateVariableComplete
|
path: var.ts:templateVariableComplete
|
||||||
events:
|
events:
|
||||||
- editor:complete
|
- editor:complete
|
||||||
|
|
||||||
applyLineReplace:
|
# Widget
|
||||||
path: ./template.ts:applyLineReplace
|
templateWidget: # Legacy
|
||||||
insertFrontMatter:
|
path: template_block.ts:widget
|
||||||
redirect: insertTemplateText
|
codeWidget: template
|
||||||
slashCommand:
|
renderMode: markdown
|
||||||
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: |
|
|
||||||
```|^|
|
|
||||||
|
|
||||||
```
|
# API invoked when a new page is created
|
||||||
|
newPage:
|
||||||
insertHRTemplate:
|
path: page.ts:newPage
|
||||||
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"
|
|
||||||
|
|
||||||
|
# Commands
|
||||||
newPageCommand:
|
newPageCommand:
|
||||||
path: ./template.ts:newPageCommand
|
path: page.ts:newPageCommand
|
||||||
command:
|
command:
|
||||||
name: "Page: From Template"
|
name: "Page: From Template"
|
||||||
key: "Alt-Shift-t"
|
key: "Alt-Shift-t"
|
||||||
|
|
||||||
insertTodayCommand:
|
|
||||||
path: "./template.ts:insertTemplateText"
|
|
||||||
slashCommand:
|
|
||||||
name: today
|
|
||||||
description: Insert today's date
|
|
||||||
value: "{{today}}"
|
|
||||||
|
|
||||||
insertTomorrowCommand:
|
# Lint
|
||||||
path: "./template.ts:insertTemplateText"
|
lintTemplateFrontmatter:
|
||||||
slashCommand:
|
path: lint.ts:lintTemplateFrontmatter
|
||||||
name: tomorrow
|
events:
|
||||||
description: Insert tomorrow's date
|
- editor:lint
|
||||||
value: "{{tomorrow}}"
|
|
||||||
|
@ -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,
|
|
||||||
});
|
|
||||||
}
|
|
@ -1,13 +1,13 @@
|
|||||||
import { markdown, space, YAML } from "$sb/syscalls.ts";
|
import { markdown, space, YAML } from "$sb/syscalls.ts";
|
||||||
import { loadPageObject, replaceTemplateVars } from "../template/template.ts";
|
import { loadPageObject, replaceTemplateVars } from "./page.ts";
|
||||||
import { CodeWidgetContent, PageMeta } from "$sb/types.ts";
|
import { CodeWidgetContent, PageMeta } from "$sb/types.ts";
|
||||||
import { renderTemplate } from "../template/plug_api.ts";
|
import { renderTemplate } from "./plug_api.ts";
|
||||||
import { renderToText } from "$sb/lib/tree.ts";
|
import { renderToText } from "$sb/lib/tree.ts";
|
||||||
import { rewritePageRefs, rewritePageRefsInString } from "$sb/lib/resolve.ts";
|
import { rewritePageRefs, rewritePageRefsInString } from "$sb/lib/resolve.ts";
|
||||||
import { performQuery } from "./query.ts";
|
import { performQuery } from "../query/widget.ts";
|
||||||
import { parseQuery } from "$sb/lib/parse-query.ts";
|
import { parseQuery } from "$sb/lib/parse-query.ts";
|
||||||
|
|
||||||
type TemplateConfig = {
|
type TemplateWidgetConfig = {
|
||||||
// Pull the template from a page
|
// Pull the template from a page
|
||||||
page?: string;
|
page?: string;
|
||||||
// Or use a string directly
|
// Or use a string directly
|
||||||
@ -29,7 +29,7 @@ export async function widget(
|
|||||||
const pageMeta: PageMeta = await loadPageObject(pageName);
|
const pageMeta: PageMeta = await loadPageObject(pageName);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const config: TemplateConfig = await YAML.parse(bodyText);
|
const config: TemplateWidgetConfig = await YAML.parse(bodyText);
|
||||||
let templateText = config.template || "";
|
let templateText = config.template || "";
|
||||||
let templatePage = config.page;
|
let templatePage = config.page;
|
||||||
if (templatePage) {
|
if (templatePage) {
|
||||||
@ -41,7 +41,13 @@ export async function widget(
|
|||||||
if (!templatePage) {
|
if (!templatePage) {
|
||||||
throw new Error("No template page specified");
|
throw new Error("No template page specified");
|
||||||
}
|
}
|
||||||
|
try {
|
||||||
templateText = await space.readPage(templatePage);
|
templateText = await space.readPage(templatePage);
|
||||||
|
} catch (e: any) {
|
||||||
|
if (e.message === "Not found") {
|
||||||
|
throw new Error(`Template page ${templatePage} not found`);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let value: any;
|
let value: any;
|
@ -1,17 +1,100 @@
|
|||||||
import { ObjectValue } from "$sb/types.ts";
|
import { ObjectValue } from "$sb/types.ts";
|
||||||
|
import { z, ZodEffects } from "zod";
|
||||||
|
|
||||||
export type TemplateFrontmatter = {
|
export const CommandConfig = z.object({
|
||||||
displayName?: string;
|
command: z.string().optional(),
|
||||||
type?: "page";
|
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 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
|
hooks: HooksConfig.optional(),
|
||||||
trigger?: string;
|
});
|
||||||
|
|
||||||
// Specific for frontmatter templates
|
export type FrontmatterConfig = z.infer<typeof FrontmatterConfig>;
|
||||||
where?: string; // expression (SB query style)
|
|
||||||
priority?: number; // When multiple templates match, the one with the highest priority is used
|
|
||||||
};
|
|
||||||
|
|
||||||
export type TemplateObject = ObjectValue<TemplateFrontmatter>;
|
export type TemplateObject = ObjectValue<FrontmatterConfig>;
|
||||||
|
44
plugs/template/var.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
@ -405,7 +405,7 @@ export class HttpServer {
|
|||||||
const args: string[] = body;
|
const args: string[] = body;
|
||||||
try {
|
try {
|
||||||
const result = await spaceServer.system!.syscall(
|
const result = await spaceServer.system!.syscall(
|
||||||
{ plug: plugName },
|
{ plug: plugName === "_" ? undefined : plugName },
|
||||||
syscall,
|
syscall,
|
||||||
args,
|
args,
|
||||||
);
|
);
|
||||||
|
@ -34,6 +34,8 @@ import { KVPrimitivesManifestCache } from "../plugos/manifest_cache.ts";
|
|||||||
import { KvPrimitives } from "../plugos/lib/kv_primitives.ts";
|
import { KvPrimitives } from "../plugos/lib/kv_primitives.ts";
|
||||||
import { ShellBackend } from "./shell_backend.ts";
|
import { ShellBackend } from "./shell_backend.ts";
|
||||||
import { ensureSpaceIndex } from "../common/space_index.ts";
|
import { ensureSpaceIndex } from "../common/space_index.ts";
|
||||||
|
import { FileMeta } from "$sb/types.ts";
|
||||||
|
import { buildQueryFunctions } from "../common/query_functions.ts";
|
||||||
|
|
||||||
// // Important: load this before the actual plugs
|
// // Important: load this before the actual plugs
|
||||||
// import {
|
// import {
|
||||||
@ -59,6 +61,7 @@ export class ServerSystem {
|
|||||||
// denoKv!: Deno.Kv;
|
// denoKv!: Deno.Kv;
|
||||||
listInterval?: number;
|
listInterval?: number;
|
||||||
ds!: DataStore;
|
ds!: DataStore;
|
||||||
|
allKnownPages = new Set<string>();
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private baseSpacePrimitives: SpacePrimitives,
|
private baseSpacePrimitives: SpacePrimitives,
|
||||||
@ -69,7 +72,10 @@ export class ServerSystem {
|
|||||||
|
|
||||||
// Always needs to be invoked right after construction
|
// Always needs to be invoked right after construction
|
||||||
async init(awaitIndex = false) {
|
async init(awaitIndex = false) {
|
||||||
this.ds = new DataStore(this.kvPrimitives);
|
this.ds = new DataStore(
|
||||||
|
this.kvPrimitives,
|
||||||
|
buildQueryFunctions(this.allKnownPages),
|
||||||
|
);
|
||||||
|
|
||||||
this.system = new System(
|
this.system = new System(
|
||||||
"server",
|
"server",
|
||||||
@ -177,6 +183,19 @@ export class ServerSystem {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
eventHook.addLocalListener(
|
||||||
|
"file:listed",
|
||||||
|
(allFiles: FileMeta[]) => {
|
||||||
|
// Update list of known pages
|
||||||
|
this.allKnownPages.clear();
|
||||||
|
allFiles.forEach((f) => {
|
||||||
|
if (f.name.endsWith(".md")) {
|
||||||
|
this.allKnownPages.add(f.name.slice(0, -3));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// Ensure a valid index
|
// Ensure a valid index
|
||||||
const indexPromise = ensureSpaceIndex(this.ds, this.system);
|
const indexPromise = ensureSpaceIndex(this.ds, this.system);
|
||||||
if (awaitIndex) {
|
if (awaitIndex) {
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
// Third party web dependencies
|
// Third party web dependencies
|
||||||
import {
|
import {
|
||||||
|
Compartment,
|
||||||
CompletionContext,
|
CompletionContext,
|
||||||
CompletionResult,
|
CompletionResult,
|
||||||
EditorView,
|
EditorView,
|
||||||
@ -52,6 +53,8 @@ import {
|
|||||||
markFullSpaceIndexComplete,
|
markFullSpaceIndexComplete,
|
||||||
} from "../common/space_index.ts";
|
} from "../common/space_index.ts";
|
||||||
import { LimitedMap } from "$sb/lib/limited_map.ts";
|
import { LimitedMap } from "$sb/lib/limited_map.ts";
|
||||||
|
import { renderHandlebarsTemplate } from "../common/syscalls/handlebars.ts";
|
||||||
|
import { buildQueryFunctions } from "../common/query_functions.ts";
|
||||||
const frontMatterRegex = /^---\n(([^\n]|\n)*?)---\n/;
|
const frontMatterRegex = /^---\n(([^\n]|\n)*?)---\n/;
|
||||||
|
|
||||||
const autoSaveInterval = 1000;
|
const autoSaveInterval = 1000;
|
||||||
@ -71,6 +74,8 @@ declare global {
|
|||||||
export class Client {
|
export class Client {
|
||||||
system!: ClientSystem;
|
system!: ClientSystem;
|
||||||
editorView!: EditorView;
|
editorView!: EditorView;
|
||||||
|
keyHandlerCompartment?: Compartment;
|
||||||
|
|
||||||
private pageNavigator!: PathPageNavigator;
|
private pageNavigator!: PathPageNavigator;
|
||||||
|
|
||||||
private dbPrefix: string;
|
private dbPrefix: string;
|
||||||
@ -136,7 +141,10 @@ export class Client {
|
|||||||
`${this.dbPrefix}_state`,
|
`${this.dbPrefix}_state`,
|
||||||
);
|
);
|
||||||
await stateKvPrimitives.init();
|
await stateKvPrimitives.init();
|
||||||
this.stateDataStore = new DataStore(stateKvPrimitives);
|
this.stateDataStore = new DataStore(
|
||||||
|
stateKvPrimitives,
|
||||||
|
buildQueryFunctions(this.allKnownPages),
|
||||||
|
);
|
||||||
|
|
||||||
// Setup message queue
|
// Setup message queue
|
||||||
this.mq = new DataStoreMQ(this.stateDataStore);
|
this.mq = new DataStoreMQ(this.stateDataStore);
|
||||||
@ -190,8 +198,7 @@ export class Client {
|
|||||||
|
|
||||||
await this.system.init();
|
await this.system.init();
|
||||||
|
|
||||||
// Load settings
|
await this.loadSettings();
|
||||||
this.settings = await ensureSettingsAndIndex(localSpacePrimitives);
|
|
||||||
|
|
||||||
await this.loadCaches();
|
await this.loadCaches();
|
||||||
// Pinging a remote space to ensure we're authenticated properly, if not will result in a redirect to auth page
|
// Pinging a remote space to ensure we're authenticated properly, if not will result in a redirect to auth page
|
||||||
@ -240,6 +247,10 @@ export class Client {
|
|||||||
this.updatePageListCache().catch(console.error);
|
this.updatePageListCache().catch(console.error);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async loadSettings() {
|
||||||
|
this.settings = await ensureSettingsAndIndex(this.space.spacePrimitives);
|
||||||
|
}
|
||||||
|
|
||||||
private async initSync() {
|
private async initSync() {
|
||||||
this.syncService.start();
|
this.syncService.start();
|
||||||
|
|
||||||
@ -303,7 +314,7 @@ export class Client {
|
|||||||
|
|
||||||
private initNavigator() {
|
private initNavigator() {
|
||||||
this.pageNavigator = new PathPageNavigator(
|
this.pageNavigator = new PathPageNavigator(
|
||||||
cleanPageRef(this.settings.indexPage),
|
cleanPageRef(renderHandlebarsTemplate(this.settings.indexPage, {}, {})),
|
||||||
);
|
);
|
||||||
|
|
||||||
this.pageNavigator.subscribe(
|
this.pageNavigator.subscribe(
|
||||||
@ -478,7 +489,12 @@ export class Client {
|
|||||||
new EventedSpacePrimitives(
|
new EventedSpacePrimitives(
|
||||||
// Using fallback space primitives here to allow (by default) local reads to "fall through" to HTTP when files aren't synced yet
|
// Using fallback space primitives here to allow (by default) local reads to "fall through" to HTTP when files aren't synced yet
|
||||||
new FallbackSpacePrimitives(
|
new FallbackSpacePrimitives(
|
||||||
new DataStoreSpacePrimitives(new DataStore(spaceKvPrimitives)),
|
new DataStoreSpacePrimitives(
|
||||||
|
new DataStore(
|
||||||
|
spaceKvPrimitives,
|
||||||
|
buildQueryFunctions(this.allKnownPages),
|
||||||
|
),
|
||||||
|
),
|
||||||
this.plugSpaceRemotePrimitives,
|
this.plugSpaceRemotePrimitives,
|
||||||
),
|
),
|
||||||
this.eventHook,
|
this.eventHook,
|
||||||
@ -487,7 +503,7 @@ export class Client {
|
|||||||
// Run when a list of files has been retrieved
|
// Run when a list of files has been retrieved
|
||||||
async () => {
|
async () => {
|
||||||
if (!this.settings) {
|
if (!this.settings) {
|
||||||
this.settings = await ensureSettingsAndIndex(localSpacePrimitives!);
|
await this.loadSettings();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof this.settings?.spaceIgnore === "string") {
|
if (typeof this.settings?.spaceIgnore === "string") {
|
||||||
@ -547,11 +563,12 @@ export class Client {
|
|||||||
"file:listed",
|
"file:listed",
|
||||||
(allFiles: FileMeta[]) => {
|
(allFiles: FileMeta[]) => {
|
||||||
// Update list of known pages
|
// Update list of known pages
|
||||||
this.allKnownPages = new Set(
|
this.allKnownPages.clear();
|
||||||
allFiles.filter((f) => f.name.endsWith(".md")).map((f) =>
|
allFiles.forEach((f) => {
|
||||||
f.name.slice(0, -3)
|
if (f.name.endsWith(".md")) {
|
||||||
),
|
this.allKnownPages.add(f.name.slice(0, -3));
|
||||||
);
|
}
|
||||||
|
});
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -638,9 +655,9 @@ export class Client {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
startPageNavigate() {
|
startPageNavigate(mode: "page" | "template") {
|
||||||
// Then show the page navigator
|
// Then show the page navigator
|
||||||
this.ui.viewDispatch({ type: "start-navigate" });
|
this.ui.viewDispatch({ type: "start-navigate", mode });
|
||||||
this.updatePageListCache().catch(console.error);
|
this.updatePageListCache().catch(console.error);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -854,7 +871,9 @@ export class Client {
|
|||||||
newWindow = false,
|
newWindow = false,
|
||||||
) {
|
) {
|
||||||
if (!name) {
|
if (!name) {
|
||||||
name = cleanPageRef(this.settings.indexPage);
|
name = cleanPageRef(
|
||||||
|
renderHandlebarsTemplate(this.settings.indexPage, {}, {}),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@ -903,6 +922,7 @@ export class Client {
|
|||||||
if (e.message.includes("Not found")) {
|
if (e.message.includes("Not found")) {
|
||||||
// Not found, new page
|
// Not found, new page
|
||||||
console.log("Page doesn't exist, creating new page:", pageName);
|
console.log("Page doesn't exist, creating new page:", pageName);
|
||||||
|
// Initialize page
|
||||||
doc = {
|
doc = {
|
||||||
text: "",
|
text: "",
|
||||||
meta: {
|
meta: {
|
||||||
@ -914,6 +934,13 @@ export class Client {
|
|||||||
perm: "rw",
|
perm: "rw",
|
||||||
} as PageMeta,
|
} as PageMeta,
|
||||||
};
|
};
|
||||||
|
this.system.system.invokeFunction("template.newPage", [pageName]).then(
|
||||||
|
() => {
|
||||||
|
this.focus();
|
||||||
|
},
|
||||||
|
).catch(
|
||||||
|
console.error,
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
this.flashNotification(
|
this.flashNotification(
|
||||||
`Could not load page ${pageName}: ${e.message}`,
|
`Could not load page ${pageName}: ${e.message}`,
|
||||||
|
@ -42,6 +42,7 @@ import { KVPrimitivesManifestCache } from "../plugos/manifest_cache.ts";
|
|||||||
import { deepObjectMerge } from "$sb/lib/json.ts";
|
import { deepObjectMerge } from "$sb/lib/json.ts";
|
||||||
import { Query } from "$sb/types.ts";
|
import { Query } from "$sb/types.ts";
|
||||||
import { PanelWidgetHook } from "./hooks/panel_widget.ts";
|
import { PanelWidgetHook } from "./hooks/panel_widget.ts";
|
||||||
|
import { createKeyBindings } from "./editor_state.ts";
|
||||||
|
|
||||||
const plugNameExtractRegex = /\/(.+)\.plug\.js$/;
|
const plugNameExtractRegex = /\/(.+)\.plug\.js$/;
|
||||||
|
|
||||||
@ -103,6 +104,12 @@ export class ClientSystem {
|
|||||||
type: "update-commands",
|
type: "update-commands",
|
||||||
commands: commandMap,
|
commands: commandMap,
|
||||||
});
|
});
|
||||||
|
// Replace the key mapping compartment (keybindings)
|
||||||
|
this.client.editorView.dispatch({
|
||||||
|
effects: this.client.keyHandlerCompartment?.reconfigure(
|
||||||
|
createKeyBindings(this.client),
|
||||||
|
),
|
||||||
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
this.system.addHook(this.commandHook);
|
this.system.addHook(this.commandHook);
|
||||||
|
@ -131,9 +131,12 @@ export function attachmentExtension(editor: Client) {
|
|||||||
if (currentNode) {
|
if (currentNode) {
|
||||||
const fencedParentNode = findParentMatching(
|
const fencedParentNode = findParentMatching(
|
||||||
currentNode,
|
currentNode,
|
||||||
(t) => t.type === "FencedCode",
|
(t) => ["FrontMatter", "FencedCode"].includes(t.type!),
|
||||||
);
|
);
|
||||||
if (fencedParentNode || currentNode.type === "FencedCode") {
|
if (
|
||||||
|
fencedParentNode ||
|
||||||
|
["FrontMatter", "FencedCode"].includes(currentNode.type!)
|
||||||
|
) {
|
||||||
console.log("Inside of fenced code block, not pasting rich text");
|
console.log("Inside of fenced code block, not pasting rich text");
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
@ -165,8 +165,13 @@ export class MarkdownWidget extends WidgetType {
|
|||||||
el.addEventListener("click", (e) => {
|
el.addEventListener("click", (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
console.info("Command link clicked in widget, running", command);
|
console.info(
|
||||||
this.client.runCommandByName(command).catch(console.error);
|
"Command link clicked in widget, running",
|
||||||
|
parsedOnclick,
|
||||||
|
);
|
||||||
|
this.client.runCommandByName(command, parsedOnclick[2]).catch(
|
||||||
|
console.error,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
@ -30,13 +30,21 @@ export class LinkWidget extends WidgetType {
|
|||||||
anchor.textContent = this.options.text;
|
anchor.textContent = this.options.text;
|
||||||
|
|
||||||
// Mouse handling
|
// Mouse handling
|
||||||
anchor.addEventListener("mousedown", (e) => {
|
anchor.addEventListener("click", (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
});
|
||||||
|
anchor.addEventListener("mouseup", (e) => {
|
||||||
if (e.button !== 0) {
|
if (e.button !== 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
try {
|
||||||
this.options.callback(e);
|
this.options.callback(e);
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Error handling wiki link click", e);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Touch handling
|
// Touch handling
|
||||||
@ -111,7 +119,7 @@ export class ButtonWidget extends WidgetType {
|
|||||||
const anchor = document.createElement("button");
|
const anchor = document.createElement("button");
|
||||||
anchor.className = this.cssClass;
|
anchor.className = this.cssClass;
|
||||||
anchor.textContent = this.text;
|
anchor.textContent = this.text;
|
||||||
anchor.addEventListener("click", (e) => {
|
anchor.addEventListener("mouseup", (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
this.callback(e);
|
this.callback(e);
|
||||||
|
@ -40,7 +40,7 @@ export function CommandPalette({
|
|||||||
});
|
});
|
||||||
if (commandOverride) {
|
if (commandOverride) {
|
||||||
shortcut = commandOverride;
|
shortcut = commandOverride;
|
||||||
console.log(`Shortcut override for ${name}:`, shortcut);
|
// console.log(`Shortcut override for ${name}:`, shortcut);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
options.push({
|
options.push({
|
||||||
|
@ -47,7 +47,10 @@ export function FilterList({
|
|||||||
}) {
|
}) {
|
||||||
const [text, setText] = useState("");
|
const [text, setText] = useState("");
|
||||||
const [matchingOptions, setMatchingOptions] = useState(
|
const [matchingOptions, setMatchingOptions] = useState(
|
||||||
fuzzySearchAndSort(options, ""),
|
fuzzySearchAndSort(
|
||||||
|
preFilter ? preFilter(options, "") : options,
|
||||||
|
"",
|
||||||
|
),
|
||||||
);
|
);
|
||||||
const [selectedOption, setSelectionOption] = useState(0);
|
const [selectedOption, setSelectionOption] = useState(0);
|
||||||
|
|
||||||
|
@ -84,7 +84,7 @@ export function MiniEditor(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}, [editorDiv]);
|
}, [editorDiv, placeholderText]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
callbacksRef.current = {
|
callbacksRef.current = {
|
||||||
|
@ -11,12 +11,14 @@ export function PageNavigator({
|
|||||||
onNavigate,
|
onNavigate,
|
||||||
completer,
|
completer,
|
||||||
vimMode,
|
vimMode,
|
||||||
|
mode,
|
||||||
darkMode,
|
darkMode,
|
||||||
currentPage,
|
currentPage,
|
||||||
}: {
|
}: {
|
||||||
allPages: PageMeta[];
|
allPages: PageMeta[];
|
||||||
vimMode: boolean;
|
vimMode: boolean;
|
||||||
darkMode: boolean;
|
darkMode: boolean;
|
||||||
|
mode: "page" | "template";
|
||||||
onNavigate: (page: string | undefined) => void;
|
onNavigate: (page: string | undefined) => void;
|
||||||
completer: (context: CompletionContext) => Promise<CompletionResult | null>;
|
completer: (context: CompletionContext) => Promise<CompletionResult | null>;
|
||||||
currentPage?: string;
|
currentPage?: string;
|
||||||
@ -72,7 +74,7 @@ export function PageNavigator({
|
|||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<FilterList
|
<FilterList
|
||||||
placeholder="Page"
|
placeholder={mode === "page" ? "Page" : "Template"}
|
||||||
label="Open"
|
label="Open"
|
||||||
options={options}
|
options={options}
|
||||||
vimMode={vimMode}
|
vimMode={vimMode}
|
||||||
@ -83,6 +85,7 @@ export function PageNavigator({
|
|||||||
return phrase;
|
return phrase;
|
||||||
}}
|
}}
|
||||||
preFilter={(options, phrase) => {
|
preFilter={(options, phrase) => {
|
||||||
|
if (mode === "page") {
|
||||||
const allTags = phrase.match(tagRegex);
|
const allTags = phrase.match(tagRegex);
|
||||||
if (allTags) {
|
if (allTags) {
|
||||||
// Search phrase contains hash tags, let's pre-filter the results based on this
|
// Search phrase contains hash tags, let's pre-filter the results based on this
|
||||||
@ -96,11 +99,21 @@ export function PageNavigator({
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
options = options.filter((pageMeta) => {
|
||||||
|
return !pageMeta.tags?.includes("template");
|
||||||
|
});
|
||||||
return options;
|
return options;
|
||||||
|
} else {
|
||||||
|
// Filter on pages tagged with "template"
|
||||||
|
options = options.filter((pageMeta) => {
|
||||||
|
return pageMeta.tags?.includes("template");
|
||||||
|
});
|
||||||
|
return options;
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
allowNew={true}
|
allowNew={true}
|
||||||
helpText="Press <code>Enter</code> to open the selected page, or <code>Shift-Enter</code> to create a new page with this exact name."
|
helpText={`Press <code>Enter</code> to open the selected ${mode}, or <code>Shift-Enter</code> to create a new ${mode} with this exact name.`}
|
||||||
newHint="Create page"
|
newHint={`Create ${mode}`}
|
||||||
completePrefix={completePrefix}
|
completePrefix={completePrefix}
|
||||||
onSelect={(opt) => {
|
onSelect={(opt) => {
|
||||||
onNavigate(opt?.name);
|
onNavigate(opt?.name);
|
||||||
|
@ -66,7 +66,7 @@ export function TopBar({
|
|||||||
|
|
||||||
// Then calculate a new width
|
// Then calculate a new width
|
||||||
currentPageElement.style.width = `${
|
currentPageElement.style.width = `${
|
||||||
Math.min(editorWidth - 170, innerDiv.clientWidth - 170)
|
Math.min(editorWidth - 200, innerDiv.clientWidth - 200)
|
||||||
}px`;
|
}px`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -14,6 +14,7 @@ export {
|
|||||||
Home as HomeIcon,
|
Home as HomeIcon,
|
||||||
RefreshCw as RefreshCwIcon,
|
RefreshCw as RefreshCwIcon,
|
||||||
Terminal as TerminalIcon,
|
Terminal as TerminalIcon,
|
||||||
|
Type as TemplateIcon,
|
||||||
} from "https://esm.sh/preact-feather@4.2.1?external=preact";
|
} from "https://esm.sh/preact-feather@4.2.1?external=preact";
|
||||||
|
|
||||||
// Vim mode
|
// Vim mode
|
||||||
|
@ -45,6 +45,7 @@ import { TextChange } from "$sb/lib/change.ts";
|
|||||||
import { postScriptPrefacePlugin } from "./cm_plugins/top_bottom_panels.ts";
|
import { postScriptPrefacePlugin } from "./cm_plugins/top_bottom_panels.ts";
|
||||||
import { languageFor } from "../common/languages.ts";
|
import { languageFor } from "../common/languages.ts";
|
||||||
import { plugLinter } from "./cm_plugins/lint.ts";
|
import { plugLinter } from "./cm_plugins/lint.ts";
|
||||||
|
import { Compartment, Extension } from "@codemirror/state";
|
||||||
|
|
||||||
export function createEditorState(
|
export function createEditorState(
|
||||||
client: Client,
|
client: Client,
|
||||||
@ -52,85 +53,16 @@ export function createEditorState(
|
|||||||
text: string,
|
text: string,
|
||||||
readOnly: boolean,
|
readOnly: boolean,
|
||||||
): EditorState {
|
): EditorState {
|
||||||
const commandKeyBindings: KeyBinding[] = [];
|
|
||||||
|
|
||||||
// Track which keyboard shortcuts for which commands we've overridden, so we can skip them later
|
|
||||||
const overriddenCommands = new Set<string>();
|
|
||||||
// Keyboard shortcuts from SETTINGS take precedense
|
|
||||||
if (client.settings?.shortcuts) {
|
|
||||||
for (const shortcut of client.settings.shortcuts) {
|
|
||||||
// Figure out if we're using the command link syntax here, if so: parse it out
|
|
||||||
const commandMatch = commandLinkRegex.exec(shortcut.command);
|
|
||||||
let cleanCommandName = shortcut.command;
|
|
||||||
let args: any[] = [];
|
|
||||||
if (commandMatch) {
|
|
||||||
cleanCommandName = commandMatch[1];
|
|
||||||
args = commandMatch[5] ? JSON.parse(`[${commandMatch[5]}]`) : [];
|
|
||||||
}
|
|
||||||
if (args.length === 0) {
|
|
||||||
// If there was no "specialization" of this command (that is, we effectively created a keybinding for an existing command but with arguments), let's add it to the overridden command set:
|
|
||||||
overriddenCommands.add(cleanCommandName);
|
|
||||||
}
|
|
||||||
commandKeyBindings.push({
|
|
||||||
key: shortcut.key,
|
|
||||||
mac: shortcut.mac,
|
|
||||||
run: (): boolean => {
|
|
||||||
client.runCommandByName(cleanCommandName, args).catch((e: any) => {
|
|
||||||
console.error(e);
|
|
||||||
client.flashNotification(
|
|
||||||
`Error running command: ${e.message}`,
|
|
||||||
"error",
|
|
||||||
);
|
|
||||||
}).then(() => {
|
|
||||||
// Always be focusing the editor after running a command
|
|
||||||
client.focus();
|
|
||||||
});
|
|
||||||
return true;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Then add bindings for plug commands
|
|
||||||
for (const def of client.system.commandHook.editorCommands.values()) {
|
|
||||||
if (def.command.key) {
|
|
||||||
// If we've already overridden this command, skip it
|
|
||||||
if (overriddenCommands.has(def.command.key)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
commandKeyBindings.push({
|
|
||||||
key: def.command.key,
|
|
||||||
mac: def.command.mac,
|
|
||||||
run: (): boolean => {
|
|
||||||
if (def.command.contexts) {
|
|
||||||
const context = client.getContext();
|
|
||||||
if (!context || !def.command.contexts.includes(context)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Promise.resolve([])
|
|
||||||
.then(def.run)
|
|
||||||
.catch((e: any) => {
|
|
||||||
console.error(e);
|
|
||||||
client.flashNotification(
|
|
||||||
`Error running command: ${e.message}`,
|
|
||||||
"error",
|
|
||||||
);
|
|
||||||
})
|
|
||||||
.then(() => {
|
|
||||||
// Always be focusing the editor after running a command
|
|
||||||
client.focus();
|
|
||||||
});
|
|
||||||
return true;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let touchCount = 0;
|
let touchCount = 0;
|
||||||
|
|
||||||
const markdownLanguage = buildMarkdown(client.system.mdExtensions);
|
const markdownLanguage = buildMarkdown(client.system.mdExtensions);
|
||||||
|
|
||||||
|
// Ugly: keep the keyhandler compartment in the client, to be replaced later once more commands are loaded
|
||||||
|
client.keyHandlerCompartment = new Compartment();
|
||||||
|
const keyBindings = client.keyHandlerCompartment.of(
|
||||||
|
createKeyBindings(client),
|
||||||
|
);
|
||||||
|
|
||||||
return EditorState.create({
|
return EditorState.create({
|
||||||
doc: text,
|
doc: text,
|
||||||
extensions: [
|
extensions: [
|
||||||
@ -209,48 +141,13 @@ export function createEditorState(
|
|||||||
{ selector: "BulletList", class: "sb-line-ul" },
|
{ selector: "BulletList", class: "sb-line-ul" },
|
||||||
{ selector: "OrderedList", class: "sb-line-ol" },
|
{ selector: "OrderedList", class: "sb-line-ol" },
|
||||||
{ selector: "TableHeader", class: "sb-line-tbl-header" },
|
{ selector: "TableHeader", class: "sb-line-tbl-header" },
|
||||||
{ selector: "FrontMatter", class: "sb-frontmatter" },
|
|
||||||
]),
|
|
||||||
keymap.of([
|
|
||||||
...commandKeyBindings,
|
|
||||||
...smartQuoteKeymap,
|
|
||||||
...closeBracketsKeymap,
|
|
||||||
...standardKeymap,
|
|
||||||
...searchKeymap,
|
|
||||||
...historyKeymap,
|
|
||||||
...completionKeymap,
|
|
||||||
indentWithTab,
|
|
||||||
{
|
{
|
||||||
key: "Ctrl-k",
|
selector: "FrontMatter",
|
||||||
mac: "Cmd-k",
|
class: "sb-frontmatter",
|
||||||
run: (): boolean => {
|
disableSpellCheck: true,
|
||||||
client.startPageNavigate();
|
|
||||||
return true;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "Ctrl-/",
|
|
||||||
mac: "Cmd-/",
|
|
||||||
run: (): boolean => {
|
|
||||||
client.ui.viewDispatch({
|
|
||||||
type: "show-palette",
|
|
||||||
context: client.getContext(),
|
|
||||||
});
|
|
||||||
return true;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "Ctrl-.",
|
|
||||||
mac: "Cmd-.",
|
|
||||||
run: (): boolean => {
|
|
||||||
client.ui.viewDispatch({
|
|
||||||
type: "show-palette",
|
|
||||||
context: client.getContext(),
|
|
||||||
});
|
|
||||||
return true;
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
]),
|
]),
|
||||||
|
keyBindings,
|
||||||
EditorView.domEventHandlers({
|
EditorView.domEventHandlers({
|
||||||
// This may result in duplicated touch events on mobile devices
|
// This may result in duplicated touch events on mobile devices
|
||||||
touchmove: () => {
|
touchmove: () => {
|
||||||
@ -366,3 +263,101 @@ export function createEditorState(
|
|||||||
],
|
],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function createKeyBindings(client: Client): Extension {
|
||||||
|
const commandKeyBindings: KeyBinding[] = [];
|
||||||
|
|
||||||
|
// Track which keyboard shortcuts for which commands we've overridden, so we can skip them later
|
||||||
|
const overriddenCommands = new Set<string>();
|
||||||
|
// Keyboard shortcuts from SETTINGS take precedense
|
||||||
|
if (client.settings?.shortcuts) {
|
||||||
|
for (const shortcut of client.settings.shortcuts) {
|
||||||
|
// Figure out if we're using the command link syntax here, if so: parse it out
|
||||||
|
const commandMatch = commandLinkRegex.exec(shortcut.command);
|
||||||
|
let cleanCommandName = shortcut.command;
|
||||||
|
let args: any[] = [];
|
||||||
|
if (commandMatch) {
|
||||||
|
cleanCommandName = commandMatch[1];
|
||||||
|
args = commandMatch[5] ? JSON.parse(`[${commandMatch[5]}]`) : [];
|
||||||
|
}
|
||||||
|
if (args.length === 0) {
|
||||||
|
// If there was no "specialization" of this command (that is, we effectively created a keybinding for an existing command but with arguments), let's add it to the overridden command set:
|
||||||
|
overriddenCommands.add(cleanCommandName);
|
||||||
|
}
|
||||||
|
commandKeyBindings.push({
|
||||||
|
key: shortcut.key,
|
||||||
|
mac: shortcut.mac,
|
||||||
|
run: (): boolean => {
|
||||||
|
client.runCommandByName(cleanCommandName, args).catch((e: any) => {
|
||||||
|
console.error(e);
|
||||||
|
client.flashNotification(
|
||||||
|
`Error running command: ${e.message}`,
|
||||||
|
"error",
|
||||||
|
);
|
||||||
|
}).then(() => {
|
||||||
|
// Always be focusing the editor after running a command
|
||||||
|
client.focus();
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Then add bindings for plug commands
|
||||||
|
for (const def of client.system.commandHook.editorCommands.values()) {
|
||||||
|
if (def.command.key) {
|
||||||
|
// If we've already overridden this command, skip it
|
||||||
|
if (overriddenCommands.has(def.command.key)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
commandKeyBindings.push({
|
||||||
|
key: def.command.key,
|
||||||
|
mac: def.command.mac,
|
||||||
|
run: (): boolean => {
|
||||||
|
if (def.command.contexts) {
|
||||||
|
const context = client.getContext();
|
||||||
|
if (!context || !def.command.contexts.includes(context)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Promise.resolve([])
|
||||||
|
.then(def.run)
|
||||||
|
.catch((e: any) => {
|
||||||
|
console.error(e);
|
||||||
|
client.flashNotification(
|
||||||
|
`Error running command: ${e.message}`,
|
||||||
|
"error",
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
// Always be focusing the editor after running a command
|
||||||
|
client.focus();
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return keymap.of([
|
||||||
|
...commandKeyBindings,
|
||||||
|
...smartQuoteKeymap,
|
||||||
|
...closeBracketsKeymap,
|
||||||
|
...standardKeymap,
|
||||||
|
...searchKeymap,
|
||||||
|
...historyKeymap,
|
||||||
|
...completionKeymap,
|
||||||
|
indentWithTab,
|
||||||
|
{
|
||||||
|
key: "Ctrl-.",
|
||||||
|
mac: "Cmd-.",
|
||||||
|
run: (): boolean => {
|
||||||
|
client.ui.viewDispatch({
|
||||||
|
type: "show-palette",
|
||||||
|
context: client.getContext(),
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
@ -12,6 +12,7 @@ import {
|
|||||||
preactRender,
|
preactRender,
|
||||||
RefreshCwIcon,
|
RefreshCwIcon,
|
||||||
runScopeHandlers,
|
runScopeHandlers,
|
||||||
|
TemplateIcon,
|
||||||
TerminalIcon,
|
TerminalIcon,
|
||||||
useEffect,
|
useEffect,
|
||||||
useReducer,
|
useReducer,
|
||||||
@ -20,6 +21,7 @@ import type { Client } from "./client.ts";
|
|||||||
import { Panel } from "./components/panel.tsx";
|
import { Panel } from "./components/panel.tsx";
|
||||||
import { h } from "./deps.ts";
|
import { h } from "./deps.ts";
|
||||||
import { sleep } from "$sb/lib/async.ts";
|
import { sleep } from "$sb/lib/async.ts";
|
||||||
|
import { template } from "https://esm.sh/v132/handlebars@4.7.7/runtime.d.ts";
|
||||||
|
|
||||||
export class MainUI {
|
export class MainUI {
|
||||||
viewState: AppViewState = initialViewState;
|
viewState: AppViewState = initialViewState;
|
||||||
@ -44,7 +46,7 @@ export class MainUI {
|
|||||||
if (ev.touches.length === 2) {
|
if (ev.touches.length === 2) {
|
||||||
ev.stopPropagation();
|
ev.stopPropagation();
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
client.startPageNavigate();
|
client.startPageNavigate("page");
|
||||||
}
|
}
|
||||||
// Launch the command palette using a three-finger tap
|
// Launch the command palette using a three-finger tap
|
||||||
if (ev.touches.length === 3) {
|
if (ev.touches.length === 3) {
|
||||||
@ -99,6 +101,7 @@ export class MainUI {
|
|||||||
<PageNavigator
|
<PageNavigator
|
||||||
allPages={viewState.allPages}
|
allPages={viewState.allPages}
|
||||||
currentPage={client.currentPage}
|
currentPage={client.currentPage}
|
||||||
|
mode={viewState.pageNavigatorMode}
|
||||||
completer={client.miniEditorComplete.bind(client)}
|
completer={client.miniEditorComplete.bind(client)}
|
||||||
vimMode={viewState.uiOptions.vimMode}
|
vimMode={viewState.uiOptions.vimMode}
|
||||||
darkMode={viewState.uiOptions.darkMode}
|
darkMode={viewState.uiOptions.darkMode}
|
||||||
@ -201,8 +204,8 @@ export class MainUI {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
console.log("Now renaming page to...", newName);
|
console.log("Now renaming page to...", newName);
|
||||||
await client.system.system.loadedPlugs.get("index")!.invoke(
|
await client.system.system.invokeFunction(
|
||||||
"renamePageCommand",
|
"index.renamePageCommand",
|
||||||
[{ page: newName }],
|
[{ page: newName }],
|
||||||
);
|
);
|
||||||
client.focus();
|
client.focus();
|
||||||
@ -244,6 +247,8 @@ export class MainUI {
|
|||||||
description: `Go to the index page (Alt-h)`,
|
description: `Go to the index page (Alt-h)`,
|
||||||
callback: () => {
|
callback: () => {
|
||||||
client.navigate("", 0);
|
client.navigate("", 0);
|
||||||
|
// And let's make sure all panels are closed
|
||||||
|
dispatch({ type: "hide-filterbox" });
|
||||||
},
|
},
|
||||||
href: "",
|
href: "",
|
||||||
},
|
},
|
||||||
@ -251,7 +256,16 @@ export class MainUI {
|
|||||||
icon: BookIcon,
|
icon: BookIcon,
|
||||||
description: `Open page (${isMacLike() ? "Cmd-k" : "Ctrl-k"})`,
|
description: `Open page (${isMacLike() ? "Cmd-k" : "Ctrl-k"})`,
|
||||||
callback: () => {
|
callback: () => {
|
||||||
client.startPageNavigate();
|
client.startPageNavigate("page");
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: TemplateIcon,
|
||||||
|
description: `Open template (${
|
||||||
|
isMacLike() ? "Cmd-Shift-t" : "Ctrl-Shift-t"
|
||||||
|
})`,
|
||||||
|
callback: () => {
|
||||||
|
client.startPageNavigate("template");
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
@ -1,6 +1,13 @@
|
|||||||
import { Hook, Manifest } from "../../plugos/types.ts";
|
import { Hook, Manifest } from "../../plugos/types.ts";
|
||||||
import { System } from "../../plugos/system.ts";
|
import { System } from "../../plugos/system.ts";
|
||||||
import { EventEmitter } from "../../plugos/event.ts";
|
import { EventEmitter } from "../../plugos/event.ts";
|
||||||
|
import { ObjectValue } from "$sb/types.ts";
|
||||||
|
import {
|
||||||
|
FrontmatterConfig,
|
||||||
|
SnippetConfig,
|
||||||
|
} from "../../plugs/template/types.ts";
|
||||||
|
import { throttle } from "$sb/lib/async.ts";
|
||||||
|
import { NewPageConfig } from "../../plugs/template/types.ts";
|
||||||
|
|
||||||
export type CommandDef = {
|
export type CommandDef = {
|
||||||
name: string;
|
name: string;
|
||||||
@ -31,10 +38,15 @@ export type CommandHookEvents = {
|
|||||||
export class CommandHook extends EventEmitter<CommandHookEvents>
|
export class CommandHook extends EventEmitter<CommandHookEvents>
|
||||||
implements Hook<CommandHookT> {
|
implements Hook<CommandHookT> {
|
||||||
editorCommands = new Map<string, AppCommand>();
|
editorCommands = new Map<string, AppCommand>();
|
||||||
|
system!: System<CommandHookT>;
|
||||||
|
|
||||||
buildAllCommands(system: System<CommandHookT>) {
|
throttledBuildAllCommands = throttle(() => {
|
||||||
|
this.buildAllCommands().catch(console.error);
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
|
async buildAllCommands() {
|
||||||
this.editorCommands.clear();
|
this.editorCommands.clear();
|
||||||
for (const plug of system.loadedPlugs.values()) {
|
for (const plug of this.system.loadedPlugs.values()) {
|
||||||
for (
|
for (
|
||||||
const [name, functionDef] of Object.entries(
|
const [name, functionDef] of Object.entries(
|
||||||
plug.manifest!.functions,
|
plug.manifest!.functions,
|
||||||
@ -52,18 +64,89 @@ export class CommandHook extends EventEmitter<CommandHookEvents>
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
await this.loadPageTemplateCommands();
|
||||||
this.emit("commandsUpdated", this.editorCommands);
|
this.emit("commandsUpdated", this.editorCommands);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async loadPageTemplateCommands() {
|
||||||
|
// This relies on two plugs being loaded: index and template
|
||||||
|
const indexPlug = this.system.loadedPlugs.get("index");
|
||||||
|
const templatePlug = this.system.loadedPlugs.get("template");
|
||||||
|
if (!indexPlug || !templatePlug) {
|
||||||
|
// Index and template plugs not yet loaded, let's wait
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Query all page templates that have a command configured
|
||||||
|
const templateCommands: ObjectValue<FrontmatterConfig>[] = await indexPlug
|
||||||
|
.invoke(
|
||||||
|
"queryObjects",
|
||||||
|
["template", {
|
||||||
|
// where hooks.newPage.command or hooks.snippet.command
|
||||||
|
filter: ["or", [
|
||||||
|
"attr",
|
||||||
|
["attr", ["attr", "hooks"], "newPage"],
|
||||||
|
"command",
|
||||||
|
], [
|
||||||
|
"attr",
|
||||||
|
["attr", ["attr", "hooks"], "snippet"],
|
||||||
|
"command",
|
||||||
|
]],
|
||||||
|
}],
|
||||||
|
);
|
||||||
|
|
||||||
|
// console.log("Template commands", templateCommands);
|
||||||
|
|
||||||
|
for (const page of templateCommands) {
|
||||||
|
try {
|
||||||
|
if (page.hooks!.newPage) {
|
||||||
|
const newPageConfig = NewPageConfig.parse(page.hooks!.newPage);
|
||||||
|
const cmdDef = {
|
||||||
|
name: newPageConfig.command!,
|
||||||
|
key: newPageConfig.key,
|
||||||
|
mac: newPageConfig.mac,
|
||||||
|
};
|
||||||
|
this.editorCommands.set(newPageConfig.command!, {
|
||||||
|
command: cmdDef,
|
||||||
|
run: () => {
|
||||||
|
return templatePlug.invoke("newPageCommand", [cmdDef, page.ref]);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (page.hooks!.snippet) {
|
||||||
|
const snippetConfig = SnippetConfig.parse(page.hooks!.snippet);
|
||||||
|
const cmdDef = {
|
||||||
|
name: snippetConfig.command!,
|
||||||
|
key: snippetConfig.key,
|
||||||
|
mac: snippetConfig.mac,
|
||||||
|
};
|
||||||
|
this.editorCommands.set(snippetConfig.command!, {
|
||||||
|
command: cmdDef,
|
||||||
|
run: () => {
|
||||||
|
return templatePlug.invoke("insertSnippetTemplate", [
|
||||||
|
{ templatePage: page.ref },
|
||||||
|
]);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error("Error loading command from", page.ref, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// console.log("Page template commands", pageTemplateCommands);
|
||||||
|
}
|
||||||
|
|
||||||
apply(system: System<CommandHookT>): void {
|
apply(system: System<CommandHookT>): void {
|
||||||
|
this.system = system;
|
||||||
system.on({
|
system.on({
|
||||||
plugLoaded: () => {
|
plugLoaded: () => {
|
||||||
this.buildAllCommands(system);
|
this.throttledBuildAllCommands();
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
// On next tick
|
// On next tick
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
this.buildAllCommands(system);
|
this.throttledBuildAllCommands();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -115,18 +115,10 @@ export class SlashCommandHook implements Hook<SlashCommandHookT> {
|
|||||||
});
|
});
|
||||||
// Replace with whatever the completion is
|
// Replace with whatever the completion is
|
||||||
safeRun(async () => {
|
safeRun(async () => {
|
||||||
const [plugName, functionName] = slashCompletion.invoke.split(
|
await this.editor.system.system.invokeFunction(
|
||||||
".",
|
slashCompletion.invoke,
|
||||||
|
[slashCompletion],
|
||||||
);
|
);
|
||||||
const plug = this.editor.system.system.loadedPlugs.get(plugName);
|
|
||||||
if (!plug) {
|
|
||||||
this.editor.flashNotification(
|
|
||||||
`Plug ${plugName} not found`,
|
|
||||||
"error",
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await plug.invoke(functionName, [slashCompletion]);
|
|
||||||
this.editor.focus();
|
this.editor.focus();
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
@ -66,6 +66,7 @@ export default function reducer(
|
|||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
showPageNavigator: true,
|
showPageNavigator: true,
|
||||||
|
pageNavigatorMode: action.mode,
|
||||||
showCommandPalette: false,
|
showCommandPalette: false,
|
||||||
showFilterBox: false,
|
showFilterBox: false,
|
||||||
};
|
};
|
||||||
@ -141,6 +142,8 @@ export default function reducer(
|
|||||||
case "hide-filterbox":
|
case "hide-filterbox":
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
|
showCommandPalette: false,
|
||||||
|
showPageNavigator: false,
|
||||||
showFilterBox: false,
|
showFilterBox: false,
|
||||||
filterBoxOnSelect: () => {},
|
filterBoxOnSelect: () => {},
|
||||||
filterBoxPlaceHolder: "",
|
filterBoxPlaceHolder: "",
|
||||||
|
@ -14,22 +14,22 @@ import { SysCallMapping } from "../../plugos/system.ts";
|
|||||||
import type { FilterOption } from "../types.ts";
|
import type { FilterOption } from "../types.ts";
|
||||||
import { UploadFile } from "../../plug-api/types.ts";
|
import { UploadFile } from "../../plug-api/types.ts";
|
||||||
|
|
||||||
export function editorSyscalls(editor: Client): SysCallMapping {
|
export function editorSyscalls(client: Client): SysCallMapping {
|
||||||
const syscalls: SysCallMapping = {
|
const syscalls: SysCallMapping = {
|
||||||
"editor.getCurrentPage": (): string => {
|
"editor.getCurrentPage": (): string => {
|
||||||
return editor.currentPage!;
|
return client.currentPage!;
|
||||||
},
|
},
|
||||||
"editor.getText": () => {
|
"editor.getText": () => {
|
||||||
return editor.editorView.state.sliceDoc();
|
return client.editorView.state.sliceDoc();
|
||||||
},
|
},
|
||||||
"editor.getCursor": (): number => {
|
"editor.getCursor": (): number => {
|
||||||
return editor.editorView.state.selection.main.from;
|
return client.editorView.state.selection.main.from;
|
||||||
},
|
},
|
||||||
"editor.getSelection": (): { from: number; to: number } => {
|
"editor.getSelection": (): { from: number; to: number } => {
|
||||||
return editor.editorView.state.selection.main;
|
return client.editorView.state.selection.main;
|
||||||
},
|
},
|
||||||
"editor.save": () => {
|
"editor.save": () => {
|
||||||
return editor.save(true);
|
return client.save(true);
|
||||||
},
|
},
|
||||||
"editor.navigate": async (
|
"editor.navigate": async (
|
||||||
_ctx,
|
_ctx,
|
||||||
@ -38,14 +38,18 @@ export function editorSyscalls(editor: Client): SysCallMapping {
|
|||||||
replaceState = false,
|
replaceState = false,
|
||||||
newWindow = false,
|
newWindow = false,
|
||||||
) => {
|
) => {
|
||||||
await editor.navigate(name, pos, replaceState, newWindow);
|
await client.navigate(name, pos, replaceState, newWindow);
|
||||||
},
|
},
|
||||||
"editor.reloadPage": async () => {
|
"editor.reloadPage": async () => {
|
||||||
await editor.reloadPage();
|
await client.reloadPage();
|
||||||
},
|
},
|
||||||
"editor.reloadUI": () => {
|
"editor.reloadUI": () => {
|
||||||
location.reload();
|
location.reload();
|
||||||
},
|
},
|
||||||
|
"editor.reloadSettingsAndCommands": async () => {
|
||||||
|
await client.loadSettings();
|
||||||
|
await client.system.commandHook.buildAllCommands();
|
||||||
|
},
|
||||||
"editor.openUrl": (_ctx, url: string, existingWindow = false) => {
|
"editor.openUrl": (_ctx, url: string, existingWindow = false) => {
|
||||||
if (!existingWindow) {
|
if (!existingWindow) {
|
||||||
const win = window.open(url, "_blank");
|
const win = window.open(url, "_blank");
|
||||||
@ -113,7 +117,7 @@ export function editorSyscalls(editor: Client): SysCallMapping {
|
|||||||
message: string,
|
message: string,
|
||||||
type: "error" | "info" = "info",
|
type: "error" | "info" = "info",
|
||||||
) => {
|
) => {
|
||||||
editor.flashNotification(message, type);
|
client.flashNotification(message, type);
|
||||||
},
|
},
|
||||||
"editor.filterBox": (
|
"editor.filterBox": (
|
||||||
_ctx,
|
_ctx,
|
||||||
@ -122,7 +126,7 @@ export function editorSyscalls(editor: Client): SysCallMapping {
|
|||||||
helpText = "",
|
helpText = "",
|
||||||
placeHolder = "",
|
placeHolder = "",
|
||||||
): Promise<FilterOption | undefined> => {
|
): Promise<FilterOption | undefined> => {
|
||||||
return editor.filterBox(label, options, helpText, placeHolder);
|
return client.filterBox(label, options, helpText, placeHolder);
|
||||||
},
|
},
|
||||||
"editor.showPanel": (
|
"editor.showPanel": (
|
||||||
_ctx,
|
_ctx,
|
||||||
@ -131,28 +135,28 @@ export function editorSyscalls(editor: Client): SysCallMapping {
|
|||||||
html: string,
|
html: string,
|
||||||
script: string,
|
script: string,
|
||||||
) => {
|
) => {
|
||||||
editor.ui.viewDispatch({
|
client.ui.viewDispatch({
|
||||||
type: "show-panel",
|
type: "show-panel",
|
||||||
id: id as any,
|
id: id as any,
|
||||||
config: { html, script, mode },
|
config: { html, script, mode },
|
||||||
});
|
});
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
// Dummy dispatch to rerender the editor and toggle the panel
|
// Dummy dispatch to rerender the editor and toggle the panel
|
||||||
editor.editorView.dispatch({});
|
client.editorView.dispatch({});
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
"editor.hidePanel": (_ctx, id: string) => {
|
"editor.hidePanel": (_ctx, id: string) => {
|
||||||
editor.ui.viewDispatch({
|
client.ui.viewDispatch({
|
||||||
type: "hide-panel",
|
type: "hide-panel",
|
||||||
id: id as any,
|
id: id as any,
|
||||||
});
|
});
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
// Dummy dispatch to rerender the editor and toggle the panel
|
// Dummy dispatch to rerender the editor and toggle the panel
|
||||||
editor.editorView.dispatch({});
|
client.editorView.dispatch({});
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
"editor.insertAtPos": (_ctx, text: string, pos: number) => {
|
"editor.insertAtPos": (_ctx, text: string, pos: number) => {
|
||||||
editor.editorView.dispatch({
|
client.editorView.dispatch({
|
||||||
changes: {
|
changes: {
|
||||||
insert: text,
|
insert: text,
|
||||||
from: pos,
|
from: pos,
|
||||||
@ -160,7 +164,7 @@ export function editorSyscalls(editor: Client): SysCallMapping {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
"editor.replaceRange": (_ctx, from: number, to: number, text: string) => {
|
"editor.replaceRange": (_ctx, from: number, to: number, text: string) => {
|
||||||
editor.editorView.dispatch({
|
client.editorView.dispatch({
|
||||||
changes: {
|
changes: {
|
||||||
insert: text,
|
insert: text,
|
||||||
from: from,
|
from: from,
|
||||||
@ -169,13 +173,13 @@ export function editorSyscalls(editor: Client): SysCallMapping {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
"editor.moveCursor": (_ctx, pos: number, center = false) => {
|
"editor.moveCursor": (_ctx, pos: number, center = false) => {
|
||||||
editor.editorView.dispatch({
|
client.editorView.dispatch({
|
||||||
selection: {
|
selection: {
|
||||||
anchor: pos,
|
anchor: pos,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (center) {
|
if (center) {
|
||||||
editor.editorView.dispatch({
|
client.editorView.dispatch({
|
||||||
effects: [
|
effects: [
|
||||||
EditorView.scrollIntoView(
|
EditorView.scrollIntoView(
|
||||||
pos,
|
pos,
|
||||||
@ -186,10 +190,10 @@ export function editorSyscalls(editor: Client): SysCallMapping {
|
|||||||
],
|
],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
editor.editorView.focus();
|
client.editorView.focus();
|
||||||
},
|
},
|
||||||
"editor.setSelection": (_ctx, from: number, to: number) => {
|
"editor.setSelection": (_ctx, from: number, to: number) => {
|
||||||
editor.editorView.dispatch({
|
client.editorView.dispatch({
|
||||||
selection: {
|
selection: {
|
||||||
anchor: from,
|
anchor: from,
|
||||||
head: to,
|
head: to,
|
||||||
@ -198,7 +202,7 @@ export function editorSyscalls(editor: Client): SysCallMapping {
|
|||||||
},
|
},
|
||||||
|
|
||||||
"editor.insertAtCursor": (_ctx, text: string) => {
|
"editor.insertAtCursor": (_ctx, text: string) => {
|
||||||
const editorView = editor.editorView;
|
const editorView = client.editorView;
|
||||||
const from = editorView.state.selection.main.from;
|
const from = editorView.state.selection.main.from;
|
||||||
editorView.dispatch({
|
editorView.dispatch({
|
||||||
changes: {
|
changes: {
|
||||||
@ -211,47 +215,55 @@ export function editorSyscalls(editor: Client): SysCallMapping {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
"editor.dispatch": (_ctx, change: Transaction) => {
|
"editor.dispatch": (_ctx, change: Transaction) => {
|
||||||
editor.editorView.dispatch(change);
|
client.editorView.dispatch(change);
|
||||||
},
|
},
|
||||||
"editor.prompt": (
|
"editor.prompt": (
|
||||||
_ctx,
|
_ctx,
|
||||||
message: string,
|
message: string,
|
||||||
defaultValue = "",
|
defaultValue = "",
|
||||||
): Promise<string | undefined> => {
|
): Promise<string | undefined> => {
|
||||||
return editor.prompt(message, defaultValue);
|
return client.prompt(message, defaultValue);
|
||||||
},
|
},
|
||||||
"editor.confirm": (_ctx, message: string): Promise<boolean> => {
|
"editor.confirm": (_ctx, message: string): Promise<boolean> => {
|
||||||
return editor.confirm(message);
|
return client.confirm(message);
|
||||||
},
|
},
|
||||||
"editor.getUiOption": (_ctx, key: string): any => {
|
"editor.getUiOption": (_ctx, key: string): any => {
|
||||||
return (editor.ui.viewState.uiOptions as any)[key];
|
return (client.ui.viewState.uiOptions as any)[key];
|
||||||
},
|
},
|
||||||
"editor.setUiOption": (_ctx, key: string, value: any) => {
|
"editor.setUiOption": (_ctx, key: string, value: any) => {
|
||||||
editor.ui.viewDispatch({
|
client.ui.viewDispatch({
|
||||||
type: "set-ui-option",
|
type: "set-ui-option",
|
||||||
key,
|
key,
|
||||||
value,
|
value,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
"editor.vimEx": (_ctx, exCommand: string) => {
|
"editor.vimEx": (_ctx, exCommand: string) => {
|
||||||
const cm = vimGetCm(editor.editorView)!;
|
const cm = vimGetCm(client.editorView)!;
|
||||||
return Vim.handleEx(cm, exCommand);
|
return Vim.handleEx(cm, exCommand);
|
||||||
},
|
},
|
||||||
|
"editor.openPageNavigator": (_ctx, mode: "page" | "template" = "page") => {
|
||||||
|
client.startPageNavigate(mode);
|
||||||
|
},
|
||||||
|
"editor.openCommandPalette": () => {
|
||||||
|
client.ui.viewDispatch({
|
||||||
|
type: "show-palette",
|
||||||
|
});
|
||||||
|
},
|
||||||
// Folding
|
// Folding
|
||||||
"editor.fold": () => {
|
"editor.fold": () => {
|
||||||
foldCode(editor.editorView);
|
foldCode(client.editorView);
|
||||||
},
|
},
|
||||||
"editor.unfold": () => {
|
"editor.unfold": () => {
|
||||||
unfoldCode(editor.editorView);
|
unfoldCode(client.editorView);
|
||||||
},
|
},
|
||||||
"editor.toggleFold": () => {
|
"editor.toggleFold": () => {
|
||||||
toggleFold(editor.editorView);
|
toggleFold(client.editorView);
|
||||||
},
|
},
|
||||||
"editor.foldAll": () => {
|
"editor.foldAll": () => {
|
||||||
foldAll(editor.editorView);
|
foldAll(client.editorView);
|
||||||
},
|
},
|
||||||
"editor.unfoldAll": () => {
|
"editor.unfoldAll": () => {
|
||||||
unfoldAll(editor.editorView);
|
unfoldAll(client.editorView);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -18,11 +18,8 @@ export async function proxySyscall(
|
|||||||
name: string,
|
name: string,
|
||||||
args: any[],
|
args: any[],
|
||||||
): Promise<any> {
|
): Promise<any> {
|
||||||
if (!ctx.plug) {
|
|
||||||
throw new Error(`Cannot proxy ${name} syscall without plug context`);
|
|
||||||
}
|
|
||||||
const resp = await httpSpacePrimitives.authenticatedFetch(
|
const resp = await httpSpacePrimitives.authenticatedFetch(
|
||||||
`${httpSpacePrimitives.url}/.rpc/${ctx.plug}/${name}`,
|
`${httpSpacePrimitives.url}/.rpc/${ctx.plug || "_"}/${name}`,
|
||||||
{
|
{
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify(args),
|
body: JSON.stringify(args),
|
||||||
|
@ -63,6 +63,9 @@ export type AppViewState = {
|
|||||||
forcedROMode: boolean;
|
forcedROMode: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Page navigator mode
|
||||||
|
pageNavigatorMode: "page" | "template";
|
||||||
|
|
||||||
// Filter box
|
// Filter box
|
||||||
showFilterBox: boolean;
|
showFilterBox: boolean;
|
||||||
filterBoxLabel: string;
|
filterBoxLabel: string;
|
||||||
@ -87,6 +90,7 @@ export const initialViewState: AppViewState = {
|
|||||||
isLoading: false,
|
isLoading: false,
|
||||||
showPageNavigator: false,
|
showPageNavigator: false,
|
||||||
showCommandPalette: false,
|
showCommandPalette: false,
|
||||||
|
pageNavigatorMode: "page",
|
||||||
unsavedChanges: false,
|
unsavedChanges: false,
|
||||||
syncFailures: 0,
|
syncFailures: 0,
|
||||||
uiOptions: {
|
uiOptions: {
|
||||||
@ -122,7 +126,7 @@ export type Action =
|
|||||||
| { type: "page-saved" }
|
| { type: "page-saved" }
|
||||||
| { type: "sync-change"; syncSuccess: boolean }
|
| { type: "sync-change"; syncSuccess: boolean }
|
||||||
| { type: "update-page-list"; allPages: PageMeta[] }
|
| { type: "update-page-list"; allPages: PageMeta[] }
|
||||||
| { type: "start-navigate" }
|
| { type: "start-navigate"; mode: "page" | "template" }
|
||||||
| { type: "stop-navigate" }
|
| { type: "stop-navigate" }
|
||||||
| {
|
| {
|
||||||
type: "update-commands";
|
type: "update-commands";
|
||||||
|
16
website/Blocks.md
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
Blocks use the fenced code block notation of [[Markdown]], and assign special behavior to it.
|
||||||
|
|
||||||
|
The general syntax is:
|
||||||
|
|
||||||
|
```block-type
|
||||||
|
block configuration
|
||||||
|
```
|
||||||
|
|
||||||
|
These are the block types that ship with SilverBullet, but [[Plugs]] can define their own:
|
||||||
|
|
||||||
|
* `template`: [[Live Templates]]
|
||||||
|
* `query`: [[Live Queries]]
|
||||||
|
* `toc`: [[Table of Contents]]
|
||||||
|
* `embed`: [[Live Embeds]]
|
||||||
|
|
||||||
|
The fenced code block syntax is also used to get [[Markdown/Syntax Highlighting]] for numerous programming languages.
|
@ -2,9 +2,16 @@ An attempt at documenting the changes/new features introduced in each
|
|||||||
release.
|
release.
|
||||||
|
|
||||||
---
|
---
|
||||||
## Next
|
## Edge
|
||||||
_Not yet released, this will likely become 0.6.0._
|
_Not yet released, this will likely become 0.6.0. To try this out now, check out [the docs on edge](https://community.silverbullet.md/t/living-on-the-edge-builds/27)._
|
||||||
|
|
||||||
|
* **Templates 2.0**: templates are now turbo charged (that’s a technical term) and have replaced a lot of previously built in (slash) commands. There’s more to this than will fit this CHANGELOG, have a look at [[Templates]]: and more specifically [[Page Templates]], [[Snippets]], [[Live Template Widgets]] and [[Libraries]].
|
||||||
|
A quick FAQ:
|
||||||
|
* **Where did my templates go!?** They have now moved to the [[Template Picker]], see that “T” button up there? Yeah, that’s new.
|
||||||
|
* **Where did all my slash commands go?!** They are now distributed via [[Libraries]]. Yep, Libraries are here, enabling an easier way to distribute templates and pages. Read [[Libraries]] for more info.
|
||||||
|
* **But, what about slash templates etc.?!** Yeah, we did some rebranding and changed how these are defined. Slash templates are now [[Snippets]] and cannot _just_ be instantiated via [[Slash Commands]], but through [[Commands]] and custom keybindings as well. Awesomeness.
|
||||||
|
* **And my page templates broke!?** Yeah, same story as with [[Snippets]]: the format for defining these changed a bit, but should be easy to update to the new format: check [[Page Templates]].
|
||||||
|
* The [[Getting Started]] page (that is embedded in the `index` page that is auto-generated when creating a new space) has been updated to include instructions on how to import the [[Library/Core]] library.
|
||||||
* **Directives have now been removed** from the code base. Please use [[Live Queries]] and [[Live Templates]] instead. If you hadn’t migrated yet and want to auto migrate, downgrade your SilverBullet version to 0.5.11 (e.g. using the `zefhemel/silverbullet:0.5.11` docker image) and run the {[Directive: Convert Entire Space to Live/Templates]} command with that version.
|
* **Directives have now been removed** from the code base. Please use [[Live Queries]] and [[Live Templates]] instead. If you hadn’t migrated yet and want to auto migrate, downgrade your SilverBullet version to 0.5.11 (e.g. using the `zefhemel/silverbullet:0.5.11` docker image) and run the {[Directive: Convert Entire Space to Live/Templates]} command with that version.
|
||||||
* (Hopefully subtle) **breaking change** in how tags work (see [[Objects]]):
|
* (Hopefully subtle) **breaking change** in how tags work (see [[Objects]]):
|
||||||
* Every object now has a `tag` attribute, signifying the “main” tag for that object (e.g. `page`, `item`)
|
* Every object now has a `tag` attribute, signifying the “main” tag for that object (e.g. `page`, `item`)
|
||||||
@ -12,17 +19,14 @@ _Not yet released, this will likely become 0.6.0._
|
|||||||
* The new `itags` attribute (available in many objects) includes both the `tag`, `tags` as well as any tags inherited from the page the object appears in.
|
* The new `itags` attribute (available in many objects) includes both the `tag`, `tags` as well as any tags inherited from the page the object appears in.
|
||||||
* Page tags now no longer need to appear at the top of the page, but can appear anywhere as long as they are the only thing appearing in a paragraph with no additional text, see [[Objects$page]].
|
* Page tags now no longer need to appear at the top of the page, but can appear anywhere as long as they are the only thing appearing in a paragraph with no additional text, see [[Objects$page]].
|
||||||
* New [[Markdown/Code Widgets|Code Widget]]: `toc` to manually include a [[Table of Contents]]
|
* New [[Markdown/Code Widgets|Code Widget]]: `toc` to manually include a [[Table of Contents]]
|
||||||
* New template type: [[Live Template Widgets]] allowing you to automatically add templates to the top or bottom of your pages (based on some criteria). Using this feature it possible to implement [[Table of Contents]] and [[Linked Mentions]] without having “hard coded” into SilverBullet itself.
|
* Filter list (used by [[Page Picker]], [[Template Picker]] and [[Command Palette]]) improvements:
|
||||||
* **“Breaking” change:** Two features are now no longer hardcoded into SilverBullet, but can be activated quite easily using [[Live Template Widgets]] (see their respective documentation pages on instructions on how to do this):
|
|
||||||
* [[Table of Contents]]
|
|
||||||
* [[Linked Mentions]]
|
|
||||||
* Filter list (used by [[Page Picker]] and [[Command Palette]]) improvements:
|
|
||||||
* Better ranking
|
* Better ranking
|
||||||
* Better positioning of modal (especially on mobile)
|
* Better positioning of modal (especially on mobile)
|
||||||
* Better mouse behavior
|
* Better mouse behavior
|
||||||
* Templates:
|
* Templates:
|
||||||
* Somewhat nicer rendering of {{templateVars}} (notice the gray background)
|
* Somewhat nicer rendering of {{templateVars}} (notice the gray background)
|
||||||
* Rendering of [[Markdown/Code Widgets]] (such as live queries and templates) **are now disabled** on template pages, which should make them less confusing to read and interpret.
|
* Rendering of [[Markdown/Code Widgets]] (such as live queries and templates) **are now disabled** on template pages, which should make them less confusing to read and interpret.
|
||||||
|
* The `indexPage` [[SETTINGS]] can now contain template variables, such as `{{today}}`
|
||||||
* Backend work in preparation for supporting more “serverless” deployments (e.g. Cloudflare workers and Deno Deploy) in the future
|
* Backend work in preparation for supporting more “serverless” deployments (e.g. Cloudflare workers and Deno Deploy) in the future
|
||||||
* Move from [Oak](https://oakserver.github.io/oak/) to [Hono](https://hono.dev/)
|
* Move from [Oak](https://oakserver.github.io/oak/) to [Hono](https://hono.dev/)
|
||||||
* Support for in-process plug loading (without workers)
|
* Support for in-process plug loading (without workers)
|
||||||
@ -73,7 +77,7 @@ _Not yet released, this will likely become 0.6.0._
|
|||||||
---
|
---
|
||||||
## 0.5.6
|
## 0.5.6
|
||||||
* Various optimization and bug fixes
|
* Various optimization and bug fixes
|
||||||
* Experimental idea: [[Template Sets]]
|
* Experimental idea: [[Libraries]]
|
||||||
* The `Alt-Shift-n` key was previously bound to both {[Page: New]} and {[Quick Note]}. That won’t work, so now it’s just bound to {[Quick Note]}
|
* The `Alt-Shift-n` key was previously bound to both {[Page: New]} and {[Quick Note]}. That won’t work, so now it’s just bound to {[Quick Note]}
|
||||||
* The `Alt-q` command is now bound to the new {[Live Queries and Templates: Refresh All]} command, refreshing all [[Live Queries]] and [[Live Templates]] on the page. This is to get y’all prepared to move away from directives.
|
* The `Alt-q` command is now bound to the new {[Live Queries and Templates: Refresh All]} command, refreshing all [[Live Queries]] and [[Live Templates]] on the page. This is to get y’all prepared to move away from directives.
|
||||||
* It’s likely that version 0.6.0 **will remove directives**, so please switch over to live queries and templates, e.g. using...
|
* It’s likely that version 0.6.0 **will remove directives**, so please switch over to live queries and templates, e.g. using...
|
||||||
@ -89,7 +93,7 @@ _Not yet released, this will likely become 0.6.0._
|
|||||||
## 0.5.4
|
## 0.5.4
|
||||||
* We’re on a journey to rethink [[Templates]]:
|
* We’re on a journey to rethink [[Templates]]:
|
||||||
* It is now _recommended_ you tag all your templates with a `#template` tag, this will exclude them from [[Objects]] indexing and may in the future be used to do better template name completion (but not yet).
|
* It is now _recommended_ you tag all your templates with a `#template` tag, this will exclude them from [[Objects]] indexing and may in the future be used to do better template name completion (but not yet).
|
||||||
* New feature: Introducing [[Slash Templates]], allowing you to create custom [[Slash Commands]]. This deprecates snippets and page templates, because [[Slash Templates]] are awesomer.
|
* New feature: Introducing [[Snippets]], allowing you to create custom [[Slash Commands]]. This deprecates snippets and page templates, because [[Snippets]] are awesomer.
|
||||||
* Many styling fixes and improvements to [[Live Queries]] and [[Live Templates]]
|
* Many styling fixes and improvements to [[Live Queries]] and [[Live Templates]]
|
||||||
* Added a “source” button to [[Live Queries]] and [[Live Templates]] for better debugging (showing you the markdown code rendered by the template so you can more easily detect issues)
|
* Added a “source” button to [[Live Queries]] and [[Live Templates]] for better debugging (showing you the markdown code rendered by the template so you can more easily detect issues)
|
||||||
* [[Live Queries]]:
|
* [[Live Queries]]:
|
||||||
@ -125,14 +129,14 @@ _Not yet released, this will likely become 0.6.0._
|
|||||||
Oh boy, this is a big one. This release brings you the following:
|
Oh boy, this is a big one. This release brings you the following:
|
||||||
|
|
||||||
* [[Objects]]: a more generic system for indexing and querying content in your space, including the ability to define your own custom object “types” (dubbed [[Tags]]). See the referenced pages for examples.
|
* [[Objects]]: a more generic system for indexing and querying content in your space, including the ability to define your own custom object “types” (dubbed [[Tags]]). See the referenced pages for examples.
|
||||||
* [[Live Queries]] and [[Live Templates]]: ultimately will replace [[🔌 Directive]] in future versions and **[[🔌 Directive]] is now deprecated.** They differ from directives in that they don’t materialize their output into the page itself, but rather render them on the fly so only the query/template instantiation is kept on disk. All previous directive examples on this website how now been replaced with [[Live Templates]] and [[Live Queries]]. To ease the conversion there is {[Directive: Convert Query to Live Query]} command: just put your cursor inside of an existing (query) directive and run it to auto-convert.
|
* [[Live Queries]] and [[Live Templates]]: ultimately will replace directives in future versions and **directives are now deprecated.** They differ from directives in that they don’t materialize their output into the page itself, but rather render them on the fly so only the query/template instantiation is kept on disk. All previous directive examples on this website how now been replaced with [[Live Templates]] and [[Live Queries]]. To ease the conversion there is {[Directive: Convert Query to Live Query]} command: just put your cursor inside of an existing (query) directive and run it to auto-convert.
|
||||||
* The query syntax used in [[Live Queries]] (but also used in [[🔌 Directive]]) has been significantly expanded, although there may still be bugs. There’s still more value to be unlocked here in future releases.
|
* The query syntax used in [[Live Queries]] (but also used in directives) has been significantly expanded, although there may still be bugs. There’s still more value to be unlocked here in future releases.
|
||||||
* The previous “backlinks” plug is now built into SilverBullet as [[Linked Mentions]] and appears at the bottom of every page (if there are incoming links). You can toggle linked mentions via {[Mentions: Toggle]}.
|
* The previous “backlinks” plug is now built into SilverBullet as [[Linked Mentions]] and appears at the bottom of every page (if there are incoming links). You can toggle linked mentions via {[Mentions: Toggle]}.
|
||||||
* A whole bunch of [[PlugOS]] syscalls have been updated. I’ll do my best update known existing plugs, but if you built existing ones some things may have broken. Please report anything broken in [Github issues](https://github.com/silverbulletmd/silverbullet/issues).
|
* A whole bunch of [[PlugOS]] syscalls have been updated. I’ll do my best update known existing plugs, but if you built existing ones some things may have broken. Please report anything broken in [Github issues](https://github.com/silverbulletmd/silverbullet/issues).
|
||||||
* This release effectively already removes the `#eval` [[🔌 Directive]] (it’s still there, but likely not working), this directive needs some rethinking. Join us on [Discord](https://discord.gg/EvXbFucTxn) if you have a use case for it and how you use/want to use it.
|
* This release effectively already removes the `#eval` (it’s still there, but likely not working), this directive needs some rethinking. Join us on [Discord](https://discord.gg/EvXbFucTxn) if you have a use case for it and how you use/want to use it.
|
||||||
|
|
||||||
**Important**:
|
**Important**:
|
||||||
* If you have plugs such as “backlinks” or “graphview” installed, please remove them (or to be safe: all plugs) from the `_plug` folder in your space after the upgrade. Then, also remove them from your [[PLUGS]] page. The backlinks plug is now included by default (named [[Linked Mentions]]), and GraphView still needs to be updated (although it’s been kind of abandoned by the author).
|
* If you have plugs such as “backlinks” or “graphview” installed, please remove them (or to be safe: all plugs) from the `_plug` folder in your space after the upgrade. Then, also remove them from your `PLUGS` page. The backlinks plug is now included by default (named [[Linked Mentions]]), and GraphView still needs to be updated (although it’s been kind of abandoned by the author).
|
||||||
|
|
||||||
Due to significant changes in how data is stored, likely your space will be resynced to all your clients once you upgrade. Just in case you may also want to {[Space: Reindex]} your space. If things are really broken, try the {[Debug: Reset Client]} command.
|
Due to significant changes in how data is stored, likely your space will be resynced to all your clients once you upgrade. Just in case you may also want to {[Space: Reindex]} your space. If things are really broken, try the {[Debug: Reset Client]} command.
|
||||||
|
|
||||||
@ -142,7 +146,7 @@ Due to significant changes in how data is stored, likely your space will be resy
|
|||||||
The big change in this release is that SilverBullet now supports two [[Client Modes|client modes]]: _online_ mode and _sync_ mode. Read more about them here: [[Client Modes]].
|
The big change in this release is that SilverBullet now supports two [[Client Modes|client modes]]: _online_ mode and _sync_ mode. Read more about them here: [[Client Modes]].
|
||||||
|
|
||||||
Other notable changes:
|
Other notable changes:
|
||||||
* Massive reshuffling of built-in [[🔌 Plugs]], splitting the old “core” plug into [[Plugs/Editor]], [[Plugs/Template]] and [[Plugs/Index]].
|
* Massive reshuffling of built-in [[Plugs]], splitting the old “core” plug into [[Plugs/Editor]], [[Plugs/Template]] and [[Plugs/Index]].
|
||||||
* Directives in [[Live Preview]] now always take up a single line height.
|
* Directives in [[Live Preview]] now always take up a single line height.
|
||||||
* [[Plugs/Tasks]] now support custom states (not just `[x]` and `[ ]`), for example:
|
* [[Plugs/Tasks]] now support custom states (not just `[x]` and `[ ]`), for example:
|
||||||
* [IN PROGRESS] An in progress task
|
* [IN PROGRESS] An in progress task
|
||||||
|
@ -1,3 +1,5 @@
|
|||||||
Commands define actions that SilverBullet can perform. They range from simple edit commands, such as {[Text: Bold]}, but may be more elaborate such as {[Page: Rename]}. At a technical level, all commands are implemented via [[Plugs]].
|
Commands define actions that SilverBullet can perform. They range from simple edit commands, such as {[Text: Bold]}, but may be more elaborate such as {[Page: Rename]}.
|
||||||
|
|
||||||
|
SilverBullet ships with a lot of commands built in, but custom ones can also be defined using [[Templates]] and [[Plugs]].
|
||||||
|
|
||||||
All available commands appear in the [[Command Palette]] but may have key bindings as well (these key bindings appear in the [[Command Palette]] and are configurable in [[SETTINGS]]).
|
All available commands appear in the [[Command Palette]] but may have key bindings as well (these key bindings appear in the [[Command Palette]] and are configurable in [[SETTINGS]]).
|
@ -4,7 +4,7 @@ This enables a few things:
|
|||||||
|
|
||||||
* **Linking and browsing** to other publicly hosted SilverBullet spaces (or websites adhering to its [[API]]). For instance the [[!silverbullet.md/CHANGELOG|SilverBullet CHANGELOG]] without leaving the comfort of your own SilverBullet client.
|
* **Linking and browsing** to other publicly hosted SilverBullet spaces (or websites adhering to its [[API]]). For instance the [[!silverbullet.md/CHANGELOG|SilverBullet CHANGELOG]] without leaving the comfort of your own SilverBullet client.
|
||||||
* **Reusing** content from externally hosted sources, such as:
|
* **Reusing** content from externally hosted sources, such as:
|
||||||
* _Templates_, e.g. by federating with `silverbullet.md/template` will give you access to the example templates hosted there without manually copying and pasting them and automatically pull in the latest version. So you can, for instance, use `render [[!silverbullet.md/template/page]]` to use the [[template/page]] template. See [[Template Sets]] for more on this use case.
|
* _Templates_, e.g. by federating with `silverbullet.md/template` will give you access to the example templates hosted there without manually copying and pasting them and automatically pull in the latest version. So you can, for instance, use `render [[!silverbullet.md/template/page]]` to use the [[Library/Core/Query/Page]] template. See [[Libraries]] for more on this use case.
|
||||||
* _Data_: such as tasks, item, data hosted elsewhere that you want to query from your own space.
|
* _Data_: such as tasks, item, data hosted elsewhere that you want to query from your own space.
|
||||||
|
|
||||||
**Note:** Federation does not support authentication yet, so all federated spaces need to be unauthenticated and will be _read-only_.
|
**Note:** Federation does not support authentication yet, so all federated spaces need to be unauthenticated and will be _read-only_.
|
||||||
|
@ -19,6 +19,19 @@ Here is another example:
|
|||||||
## This is a section
|
## This is a section
|
||||||
This is content
|
This is content
|
||||||
|
|
||||||
|
For convenience, you may use the `attribute.subAttribute` notation, which internally will expand:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
attribute.subAttribute: 10
|
||||||
|
```
|
||||||
|
|
||||||
|
to
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
attribute:
|
||||||
|
subAttribute: 10
|
||||||
|
```
|
||||||
|
|
||||||
# Special attributes
|
# Special attributes
|
||||||
While SilverBullet allows arbitrary metadata to be added to pages, there are a few attributes with special meaning:
|
While SilverBullet allows arbitrary metadata to be added to pages, there are a few attributes with special meaning:
|
||||||
|
|
||||||
|
@ -1,17 +1,23 @@
|
|||||||
|
Welcome to SilverBullet. Since you’re starting fresh, you may want to kick off by importing the [[Library/Core]] [[Libraries|library]] of templates and pages. You can do so easily with the button below. Just push it — you know you want to!
|
||||||
|
|
||||||
|
{[Library: Import|Import Core Library]("!silverbullet.md/Library/Core/")}
|
||||||
|
|
||||||
|
Did that? Let’s proceed.
|
||||||
|
|
||||||
## Getting started
|
## Getting started
|
||||||
The best way to get a good feel for what SilverBullet is to immediately start playing with it. Here are some things for you to try:
|
The best way to get a good feel for what SilverBullet is to immediately start playing with it. Here are some things for you to try:
|
||||||
|
|
||||||
* Click on the page picker (book icon) icon at the top right, or hit `Cmd-k` (Mac) or `Ctrl-k` (Linux and Windows) to open the **page switcher**.
|
* Click on the page picker (book icon) icon at the top right, or hit `Cmd-k` (Mac) or `Ctrl-k` (Linux and Windows) to open the [[Page Picker]].
|
||||||
* Type the name of a non-existent page to create it.
|
* Type the name of a non-existent page to create it.
|
||||||
* You _can_ create pages in folders (if you’re into that type of thing) simply by putting slashes (`/`) in the name (even on Windows), e.g. `My Folder/My Page`. Don’t worry about that folder existing, we’ll automatically create it if it doesn’t.
|
* You _can_ create pages in folders (if you’re into that type of thing) simply by putting slashes (`/`) in the name (even on Windows), e.g. `My Folder/My Page`. Don’t worry about that folder existing, we’ll automatically create it if it doesn’t.
|
||||||
* Click on the terminal icon (top right), hit `Cmd-/` (Mac) or `Ctrl-/` (Linux and Windows), or tap the screen with 3 fingers at the same time (on mobile) to open the **command palette**. The {[Stats: Show]} one is a safe one to try.
|
* Click on the terminal icon (top right), or hit `Cmd-/` (Mac) or `Ctrl-/` (Linux and Windows), or tap the screen with 3 fingers at the same time (on mobile) to open the [[Command Palette]]. The {[Stats: Show]} one is a safe one to try.
|
||||||
|
* Click on the “T” icon (top right), or hit `Cmd-Shift-t` (Mac) or `Ctrl-Shift-t` (Linux and Windows) to open the [[Template Picker]] and see what templates you have installed (which should be a few after importing the Core library)
|
||||||
* Select some text and hit `Alt-m` to ==highlight== it, or `Cmd-b` (Mac) or `Ctrl-b` (Windows/Linux) to make it **bold**, or `Cmd-i` (Mac) or `Ctrl-i` (Windows/Linux) to make it _italic_.
|
* Select some text and hit `Alt-m` to ==highlight== it, or `Cmd-b` (Mac) or `Ctrl-b` (Windows/Linux) to make it **bold**, or `Cmd-i` (Mac) or `Ctrl-i` (Windows/Linux) to make it _italic_.
|
||||||
* Click a link somewhere on this page to navigate there. When you link to a new page it will initially show up in red (to indicate it does not yet exist), but once you click it — you will create the page automatically (only for real when you actually enter some text).
|
* Click a link somewhere on this page to navigate there. When you link to a new page it will initially show up in red (to indicate it does not yet exist), but once you click it — you will create the page automatically (only for real when you actually enter some text).
|
||||||
* Start typing `[[` somewhere to insert your own page link (with completion).
|
* Start typing `[[` somewhere to insert your own page link (with completion).
|
||||||
* [ ] Tap this box 👈 to mark this task as done.
|
* [ ] Tap this box 👈 to mark this task as done.
|
||||||
* Start typing `:party` to trigger the emoji picker 🎉
|
* Start typing `:party` to trigger the emoji picker 🎉
|
||||||
* Type `/` somewhere in the text to invoke a **slash command**.
|
* Type `/` somewhere in the text to invoke a **slash command**.
|
||||||
* Hit `Cmd-p` (Mac) or `Ctrl-p` (Windows, Linux) to show a preview for the current page on the side.
|
|
||||||
* If this is matching your personality type, you can click this button {[Editor: Toggle Vim Mode]} to toggle Vim mode. If you cannot figure out how to exit it, just click that button again. _Phew!_
|
* If this is matching your personality type, you can click this button {[Editor: Toggle Vim Mode]} to toggle Vim mode. If you cannot figure out how to exit it, just click that button again. _Phew!_
|
||||||
|
|
||||||
Notice that as you move your cursor around on this page and you get close to or “inside” marked up text, you will get to see the underlying [[Markdown]] code. This experience is what we refer to as “live preview” — generally your text looks clean, but you still can see what’s under the covers and edit it directly, as opposed to [WYSIWYG](https://en.wikipedia.org/wiki/WYSIWYG) that some other applications use. To move your cursor somewhere using your mouse without navigating or activating (e.g. a wiki, regular link, or a button) hold `Alt` when you click. Holding `Cmd` or `Ctrl` when clicking a link will open it in a new tab or window.
|
Notice that as you move your cursor around on this page and you get close to or “inside” marked up text, you will get to see the underlying [[Markdown]] code. This experience is what we refer to as “live preview” — generally your text looks clean, but you still can see what’s under the covers and edit it directly, as opposed to [WYSIWYG](https://en.wikipedia.org/wiki/WYSIWYG) that some other applications use. To move your cursor somewhere using your mouse without navigating or activating (e.g. a wiki, regular link, or a button) hold `Alt` when you click. Holding `Cmd` or `Ctrl` when clicking a link will open it in a new tab or window.
|
||||||
@ -36,4 +42,4 @@ Beyond that, you can find more information about SilverBullet on its official we
|
|||||||
1. Through its [regular website link](https://silverbullet.md/)
|
1. Through its [regular website link](https://silverbullet.md/)
|
||||||
2. Directly without leaving SilverBullet, through [[Federation]], just click on this: [[SilverBullet]] (note that all of these will be read-only, for obvious reasons)
|
2. Directly without leaving SilverBullet, through [[Federation]], just click on this: [[SilverBullet]] (note that all of these will be read-only, for obvious reasons)
|
||||||
|
|
||||||
To keep up with the latest and greatest going-ons in SilverBullet land, keep an eye on the [[CHANGELOG]], and regularly update your SilverBullet instance (`silverbullet upgrade` if you’re running the Deno version). If you run into any issues or have ideas on how to make SilverBullet even awesomer (yes, that’s a word), [join the conversation on GitHub](https://github.com/silverbulletmd/silverbullet).
|
To keep up with the latest and greatest going-ons in SilverBullet land, keep an eye on the [[CHANGELOG]].
|
@ -1,247 +0,0 @@
|
|||||||
In this guide we will show you how to deploy silverbullet and cloudflare in containers, making them "talk/communicate" in the same private network just for them.
|
|
||||||
|
|
||||||
This guide assumes that you have already deployed Portainer. If not, see [this official guide](https://docs.portainer.io/start/install-ce/server/docker/linux) from Portainer to deploy it on Linux.
|
|
||||||
|
|
||||||
### Brief
|
|
||||||
|
|
||||||
This guide will be divided into three parts, in the first we'll set up Silverbullet with Cloudflare. In the second, we will set up Cloudflare from the beginning to access Silverbullet from outside our LAN using [Tunnels](https://www.cloudflare.com/products/tunnel/). And in the third step, we protect our Silverbullet instance with [Access Zero Trust](https://www.cloudflare.com/products/zero-trust/access/) for authentication.
|
|
||||||
|
|
||||||
# 1 - Deploy Silverbullet and Cloudflare in Portainer
|
|
||||||
|
|
||||||
## Prepare the Template
|
|
||||||
We will prepare a template in Portainer where we will add the configuration of a ==docker-compose.yaml== that will run our containers, and we will be able to move the stack to another server/host if necessary using the same configuration.
|
|
||||||
|
|
||||||
First, go to **Home** > (Your environment name, default is **local**) > **App Templates** > **Custom Templates** and click on the blue button in the right corner > "**Add Custom Template**".
|
|
||||||
![](create-custom-template.png)
|
|
||||||
|
|
||||||
### Name
|
|
||||||
|
|
||||||
Choose a name for the silverbullet stack, we chose "**silverbullet-docker**", very imaginative... 😊.
|
|
||||||
|
|
||||||
### Description
|
|
||||||
|
|
||||||
Fill the description with your own words; this is up to you because it is optional.
|
|
||||||
|
|
||||||
### Icon Url
|
|
||||||
|
|
||||||
Copy and paste this url to get the icon. ``https://raw.githubusercontent.com/silverbulletmd/silverbullet/main/web/images/logo.ico``
|
|
||||||
|
|
||||||
### Platform
|
|
||||||
|
|
||||||
Choose Linux
|
|
||||||
|
|
||||||
### Type
|
|
||||||
|
|
||||||
Standalone
|
|
||||||
|
|
||||||
### Build Method
|
|
||||||
|
|
||||||
As for the Build method choose “**Web Editor**” and copy-paste this ==docker-compose.yaml== configuration:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
version: '3.9'
|
|
||||||
services:
|
|
||||||
silverbullet:
|
|
||||||
image: zefhemel/silverbullet
|
|
||||||
container_name: silverbullet
|
|
||||||
restart: unless-stopped
|
|
||||||
## To enable additional options, such as authentication, set environment variables, e.g.
|
|
||||||
environment:
|
|
||||||
- PUID=1000
|
|
||||||
- PGID=1000
|
|
||||||
#- SB_USER=username:1234 #feel free to remove this if not needed
|
|
||||||
volumes:
|
|
||||||
- space:/space:rw
|
|
||||||
ports:
|
|
||||||
- 3000:3000
|
|
||||||
networks:
|
|
||||||
- silverbullet
|
|
||||||
|
|
||||||
cloudflared:
|
|
||||||
container_name: cloudflared-tunnel
|
|
||||||
image: cloudflare/cloudflared
|
|
||||||
restart: unless-stopped
|
|
||||||
command: tunnel run
|
|
||||||
environment:
|
|
||||||
# If deploying in to Portainer add your token value here!
|
|
||||||
# If deploying manually create a ".env" file and add the variable and the value of the token.
|
|
||||||
- TUNNEL_TOKEN=your-token-value-here!
|
|
||||||
#- TUNNEL_TOKEN=${TUNNEL_TOKEN}
|
|
||||||
depends_on:
|
|
||||||
- silverbullet
|
|
||||||
networks:
|
|
||||||
- silverbullet
|
|
||||||
|
|
||||||
networks:
|
|
||||||
silverbullet:
|
|
||||||
external: true
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
space:
|
|
||||||
```
|
|
||||||
|
|
||||||
We will replace "your-token-value-here" with a real token value in the next steps.
|
|
||||||
|
|
||||||
Once you have this, go to the bottom of the page and click **Actions** > **Create Custom Template**.
|
|
||||||
![](create-custom-template-4.png)
|
|
||||||
|
|
||||||
Now we have to build the network before we can deploy it.
|
|
||||||
|
|
||||||
**NOTE***: If you got a *Error code 8: Attempt to write a readonly database* when running `docker compose up`.
|
|
||||||
|
|
||||||
Ensure that the directory on the host system that is mounted as /space in your container has the correct permissions. For example:
|
|
||||||
|
|
||||||
```shell
|
|
||||||
sudo chown -R 1000:1000 /path/to/space
|
|
||||||
sudo chmod -R 755 /path/to/space
|
|
||||||
```
|
|
||||||
|
|
||||||
## Create the network for silverbullet
|
|
||||||
|
|
||||||
Go to **Home** > **Networks** > **Add Network**.
|
|
||||||
|
|
||||||
### Name
|
|
||||||
|
|
||||||
Choose "**silverbullet**" because that is the name we are already using in the ==docker-compose.yaml==.
|
|
||||||
|
|
||||||
You can leave all the other options by default or change them to suit your network needs.
|
|
||||||
|
|
||||||
![](create-network-1.png)
|
|
||||||
![](create-network-2.png)
|
|
||||||
Click **Create Network** at the bottom of the page.
|
|
||||||
![](create-network-4.png)
|
|
||||||
|
|
||||||
## Deploying the Stack
|
|
||||||
|
|
||||||
Go to **Home** > **Local** > **App Templates** > **Custom Templates**.
|
|
||||||
|
|
||||||
Go into the **silverbullet-docker** and click on **Edit**.
|
|
||||||
![](deploy-stack-3.png)
|
|
||||||
Click on **Deploy the stack**.
|
|
||||||
![](deploy-stack-2.png)
|
|
||||||
Give it a few seconds and you will get a notification that both containers are running. 😇
|
|
||||||
|
|
||||||
Only the silverbullet container should be working properly by this point, as we haven't finished with Cloudflare yet.
|
|
||||||
![](view-containers-1.png)
|
|
||||||
|
|
||||||
## Verification
|
|
||||||
|
|
||||||
In a web browser in your local network (if your server is in your LAN) write the IP address of your server and add the port 3000 at the end, like this:
|
|
||||||
``http://your-ip-address:3000 ``
|
|
||||||
|
|
||||||
Right now the connection to silverbullet is **HTTP** and PWA([Progressive Web Apps](https://developer.mozilla.org/en-US/docs/Web/Progressive_web_apps)) and offline mode will not work yet. Don’t worry we will get into that later, but for now, it should be working correctly. Try to type something and sync it to your server.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# 2 - Set up Cloudflare with Tunnels.
|
|
||||||
|
|
||||||
Now we are going to use Cloudflare to be able to connect to SilverBullet from outside our network and have a valid SSL certificate without opening any ports or needing a static IPv4 address from our ISP or changing our router configuration.
|
|
||||||
|
|
||||||
You will need three things:
|
|
||||||
|
|
||||||
* An account with Cloudflare ☁️.
|
|
||||||
* A debit/credit card 💳.
|
|
||||||
* A domain name (you can buy it on [Njalla](https://njal.la/) 😉. Your real name will not be shown if someone uses whois tools).
|
|
||||||
|
|
||||||
We assume you've already [signed up to Cloudflare](https://www.cloudflare.com/), if not you can go and do it now. It's free but you'll need to add a real debit/credit card to have access to the tunnels and zero access. If you don't want to do that, you can use **alternatives** like [Caddy](https://caddyserver.com/docs/quick-starts/reverse-proxy) or [Nginx](https://docs.nginx.com/nginx/admin-guide/web-server/reverse-proxy/) for reverse proxy and [Authelia](https://www.authelia.com/) or you can use the [basic authentication built-in](https://silverbullet.md/Authentication) for authentication.
|
|
||||||
|
|
||||||
## Add your Site/Domain Name to Cloudflare
|
|
||||||
|
|
||||||
Follow the [official docs](https://developers.cloudflare.com/fundamentals/get-started/setup/add-site/) of Cloudflare on how to add a site, it's really easy, just remember to change the name servers (DNS) to the ones suggested by Cloudflare in the website where you bought your domain name.
|
|
||||||
![](create-site-cloudflare-1.png)
|
|
||||||
Like this (This is Njalla config panel)
|
|
||||||
![](create-site-cloudflare-custom_dns.png)
|
|
||||||
|
|
||||||
## Setup Tunnel
|
|
||||||
|
|
||||||
Without opening any ports or touching the firewall, we set up this tunnel to connect it to our server.
|
|
||||||
|
|
||||||
Click on **Zero Trust** once you have added your site/domain name.
|
|
||||||
![](setup-tunnel-1.png)
|
|
||||||
Click on **Create Tunnel**.
|
|
||||||
![](setup-tunnels-2.png)
|
|
||||||
Choose a name for your tunnel, I chose "myhome", very imaginative again 😛. And then click on **Save Tunnel**.
|
|
||||||
![](setup-tunnels-3.png)
|
|
||||||
|
|
||||||
Since we have already set up a container of Cloudflare, just copy the token you are given. And be careful, if someone gets your token they will be able to make a tunnel connection to your server.
|
|
||||||
![](setup-tunnels-4_2.png)
|
|
||||||
|
|
||||||
Now that you have the token value of your tunnel, it's time to configure the cloudflare container in Portainer. Let's go there.
|
|
||||||
|
|
||||||
Go to **App Templates** > **Custom Templates** > **Edit**.
|
|
||||||
![](deploy-stack-3.png)
|
|
||||||
Replace “your-token-value-here!” with your token value.
|
|
||||||
![](setup-tunnels-6.png)
|
|
||||||
Click on **Update the template**.****
|
|
||||||
|
|
||||||
Next, go to **Stacks** and click on the stack “**silverbullet-docker**”, or the name of your choice, then click **Remove**.
|
|
||||||
![](remove-stack-1.png)
|
|
||||||
Click **Remove** to confirm. Don't worry, this will only remove the stack and the containers attached to it, not the template.
|
|
||||||
![](remove-stack-2.png)
|
|
||||||
Then go to **App Templates**.
|
|
||||||
|
|
||||||
Go into the **silverbullet-docker** and click on **Edit**.
|
|
||||||
![](deploy-stack-3.png)
|
|
||||||
Click **Deploy Stack**.
|
|
||||||
![](deploy-stack-2.png)
|
|
||||||
Come back to Cloudflare and in the Connectors section you will see that a connection has been made to your server. Click **Next**.
|
|
||||||
![](setup-tunnels-7.png)
|
|
||||||
Click **Add a public hostname**.
|
|
||||||
![](setup-tunnels-9.png)
|
|
||||||
Fill in the **subdomain** field with the name you want to use to access silverbullet. Choose your domain name and for **Type** choose **HTTP** and the **URL** should be **silverbullet:3000**.
|
|
||||||
|
|
||||||
![](setup-tunnels-11.png)
|
|
||||||
Check now with **silberbullet.your-domain-name.com**. You should be able to access it.
|
|
||||||
|
|
||||||
# 3 - Set up Cloudflare Zero Access Trust (Auth).
|
|
||||||
|
|
||||||
We assume you've already [signed up to Cloudflare](https://www.cloudflare.com/), if not you can go and do it now, it's free but you'll need to add a real debit/credit card to have access to the tunnels and zero access. If you don't want to do that, you can use **alternatives** like [Caddy](https://caddyserver.com/docs/quick-starts/reverse-proxy) or [Nginx](https://docs.nginx.com/nginx/admin-guide/web-server/reverse-proxy/) for reverse proxy and [Authelia](https://www.authelia.com/) or you can use the [BasicAuth build-in](https://silverbullet.md/Authentication) for authentication.
|
|
||||||
|
|
||||||
Go to **Access** > **Applications** and click **Add an application** from the Zero Trust panel.
|
|
||||||
![](add-application-clodflare-3.png)
|
|
||||||
|
|
||||||
Select **Self-Hosted**.
|
|
||||||
![](add-application-clodflare-2.png)
|
|
||||||
Choose a name for your application and use the same name for the subdomain you chose in the previous steps. In our case both are **silverbullet**.
|
|
||||||
![](add-application-clodflare-4.png)
|
|
||||||
Leave the rest of the page as default and click **Next** at the bottom of the page.
|
|
||||||
|
|
||||||
Now it's time to select the name of the policy, the action and the duration of the session.
|
|
||||||
|
|
||||||
Select a descriptive **Name** for future troubleshooting, select **Allow** for the **Action** and leave the session duration at its default.
|
|
||||||
|
|
||||||
In the **Configure rules** section, select **Emails** if you want to use emails (or you can use a range of IPs, specific countries...) for verification, and enter the emails you want to allow access to Silverbullet.
|
|
||||||
![](add-application-clodflare-5.png)
|
|
||||||
Leave the rest of the page as default and click **Next** at the bottom of the page.
|
|
||||||
|
|
||||||
On the next page, leave everything as default and click on **Add Application** at the bottom of the page.
|
|
||||||
|
|
||||||
Go to **silverbullet.your-domain-name.com** and you should see a page like this:
|
|
||||||
![](add-application-clodflare-6.png)
|
|
||||||
Going back to the Zero Trust overview, we are now going to create some special rules to allow some specific files from silverbullet without authentication. The same thing happens with other auth applications such as [Authelia](https://silverbullet.md/Authelia).
|
|
||||||
|
|
||||||
Create a new self-hosted application in Cloudflare, we suggest the name **silverbullet bypass**.
|
|
||||||
|
|
||||||
And add the following **paths**:
|
|
||||||
|
|
||||||
```
|
|
||||||
.client/manifest.json
|
|
||||||
.client/[a-zA-Z0-9_-]+.png
|
|
||||||
service_worker.js
|
|
||||||
```
|
|
||||||
|
|
||||||
Leave the rest as default and click **Next** at the bottom of the page.
|
|
||||||
![](add-application-clodflare-7.png)
|
|
||||||
For the policy name we suggest **silverbullet bypass paths**, as for the **Action** you need to select **Bypass**, and in the Configure Rules **Select** **Everyone** or you can exclude a range of IP's or countries if required.
|
|
||||||
|
|
||||||
Leave the rest as default and click **Next** at the bottom of the page.
|
|
||||||
![](add-application-clodflare-8.png)
|
|
||||||
These rules only take effect on the specific paths, you can read more about [Policy inheritance on Cloudflare.](https://developers.cloudflare.com/cloudflare-one/policies/access/app-paths/)
|
|
||||||
|
|
||||||
On the next page, leave everything as default and click on **Add Application** at the bottom of the page.
|
|
||||||
|
|
||||||
Go and check your **silberbullet.your-domain-name.com** everything should be working correctly.
|
|
||||||
|
|
||||||
Now the connection to silverbullet is **HTTPS** and PWA ([Progressive Web Apps](https://developer.mozilla.org/en-US/docs/Web/Progressive_web_apps)) and offline mode will work.
|
|
||||||
|
|
||||||
I hope this guide has been helpful.
|
|
Before Width: | Height: | Size: 86 KiB |
Before Width: | Height: | Size: 70 KiB |
Before Width: | Height: | Size: 72 KiB |
Before Width: | Height: | Size: 91 KiB |
Before Width: | Height: | Size: 143 KiB |
Before Width: | Height: | Size: 94 KiB |
Before Width: | Height: | Size: 93 KiB |
Before Width: | Height: | Size: 73 KiB |
Before Width: | Height: | Size: 84 KiB |
Before Width: | Height: | Size: 109 KiB |
Before Width: | Height: | Size: 115 KiB |
Before Width: | Height: | Size: 59 KiB |
Before Width: | Height: | Size: 64 KiB |
Before Width: | Height: | Size: 52 KiB |
Before Width: | Height: | Size: 186 KiB |
Before Width: | Height: | Size: 113 KiB |
Before Width: | Height: | Size: 79 KiB |
Before Width: | Height: | Size: 26 KiB |
Before Width: | Height: | Size: 64 KiB |
Before Width: | Height: | Size: 84 KiB |
Before Width: | Height: | Size: 82 KiB |
Before Width: | Height: | Size: 82 KiB |
Before Width: | Height: | Size: 252 KiB |
Before Width: | Height: | Size: 208 KiB |
Before Width: | Height: | Size: 97 KiB |
Before Width: | Height: | Size: 60 KiB |
Before Width: | Height: | Size: 123 KiB |
@ -14,4 +14,3 @@ People have found various simple to more complex ways of achieving this.
|
|||||||
* Using [[Deployments/ngrok]] is likely the easiest solution to exposing your _locally running_ SilverBullet to the Internet. Note that “locally running” can mean your own local machine, but can still refer to running it on a server in your network (like a Raspberry Pi).
|
* Using [[Deployments/ngrok]] is likely the easiest solution to exposing your _locally running_ SilverBullet to the Internet. Note that “locally running” can mean your own local machine, but can still refer to running it on a server in your network (like a Raspberry Pi).
|
||||||
* [[Deployments/Caddy]]: the easiest solution to expose SilverBullet running on a publicly accessible server to the Internet (but local network as well using Tailscale)
|
* [[Deployments/Caddy]]: the easiest solution to expose SilverBullet running on a publicly accessible server to the Internet (but local network as well using Tailscale)
|
||||||
* [[Authelia]] setup hints
|
* [[Authelia]] setup hints
|
||||||
* [[Guide/Deployment/Cloudflare and Portainer]]
|
|
||||||
|
@ -9,8 +9,6 @@ Particularly useful keyboard shortcuts (that you may not know about).
|
|||||||
| Cmd-z/Ctrl-z | Undo the latest change |
|
| Cmd-z/Ctrl-z | Undo the latest change |
|
||||||
| Cmd-u/Ctrl-u | Go one change ahead |
|
| Cmd-u/Ctrl-u | Go one change ahead |
|
||||||
| Alt-h | Navigate to the home page |
|
| Alt-h | Navigate to the home page |
|
||||||
| Ctrl-Alt-t | Toggle table of contents|
|
|
||||||
| Ctrl-Alt-m | Toggle mentions |
|
|
||||||
| Cmd-Shift-f/Ctrl-Shift-f | Search for text across your entire space |
|
| Cmd-Shift-f/Ctrl-Shift-f | Search for text across your entire space |
|
||||||
# System
|
# System
|
||||||
| Combination (Mac/Win-Linux) | Action |
|
| Combination (Mac/Win-Linux) | Action |
|
||||||
@ -20,8 +18,6 @@ Particularly useful keyboard shortcuts (that you may not know about).
|
|||||||
| Cmd-Shift-p/Ctrl-Shift-p | Update plugs (from the `PLUGS` file) |
|
| Cmd-Shift-p/Ctrl-Shift-p | Update plugs (from the `PLUGS` file) |
|
||||||
| Alt-q | Refresh all live queries and templates on this page |
|
| Alt-q | Refresh all live queries and templates on this page |
|
||||||
| Cmd-p/Ctrl-p | Toggle markdown preview |
|
| Cmd-p/Ctrl-p | Toggle markdown preview |
|
||||||
| Ctrl-Alt-t | Toggle table of contents|
|
|
||||||
| Ctrl-Alt-m | Toggle mentions |
|
|
||||||
| Cmd-Shift-f/Ctrl-Shift-f | Search for text across your entire space |
|
| Cmd-Shift-f/Ctrl-Shift-f | Search for text across your entire space |
|
||||||
|
|
||||||
# Navigation
|
# Navigation
|
||||||
|
16
website/Libraries.md
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
A lot of useful functionality in SilverBullet is implemented through [[Templates]], as well as regular [[Pages]]. Some of these you will create yourself for your own specific use, but many are generic and generally useful. Libraries offer a way to _distribute_ sets of templates and pages easily.
|
||||||
|
|
||||||
|
# What’s in a library
|
||||||
|
Here are some things that a library may provide:
|
||||||
|
* Various [[Slash Commands]], such as `/today`, `/task`, `/table`.
|
||||||
|
* Useful [[Page Templates]]
|
||||||
|
* Useful widgets such as [[Table of Contents]] or [[Linked Mentions]]
|
||||||
|
* Useful pages that help you perform maintenance on your space, like detecting broken links, such as [[Library/Core/Page/Maintenance]].
|
||||||
|
|
||||||
|
# What libraries are on offer?
|
||||||
|
Libraries are still a young concept in SilverBullet and therefore we’re still exploring how to organize and structure these.
|
||||||
|
|
||||||
|
Currently, we have the following libraries available:
|
||||||
|
|
||||||
|
* [[Library/Core]]: this is the library you want to import _for sure_. Just do it.
|
||||||
|
* [[Library/Journal]]: for the journalers among us.
|