1
0

Templates 2.0 (#636)

Templates 2.0 and a whole bunch of other refactoring
This commit is contained in:
Zef Hemel 2024-01-20 19:16:07 +01:00 committed by GitHub
parent 0a6a0016a2
commit f30b1d3418
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
171 changed files with 2038 additions and 1628 deletions

View File

@ -48,13 +48,7 @@ export async function runPlug(
}, app.fetch);
if (functionName) {
const [plugName, funcName] = functionName.split(".");
const plug = serverSystem.system.loadedPlugs.get(plugName);
if (!plug) {
throw new Error(`Plug ${plugName} not found`);
}
const result = await plug.invoke(funcName, args);
const result = await serverSystem.system.invokeFunction(functionName, args);
await serverSystem.close();
serverSystem.kvPrimitives.close();
serverController.abort();

View File

@ -6,3 +6,11 @@ A list of built-in settings [[!silverbullet.md/SETTINGS|can be found here]].
indexPage: index
\`\`\`
`;
export const INDEX_TEMPLATE =
`Hello! And welcome to your brand new SilverBullet space!
\`\`\`template
page: "[[!silverbullet.md/Getting Started]]"
\`\`\`
`;

View File

@ -71,7 +71,12 @@ export {
Text,
Transaction,
} from "@codemirror/state";
export type { ChangeSpec, Extension, StateCommand } from "@codemirror/state";
export type {
ChangeSpec,
Compartment,
Extension,
StateCommand,
} from "@codemirror/state";
export {
codeFolding,
defaultHighlightStyle,
@ -132,3 +137,5 @@ export {
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 { z } from "https://deno.land/x/zod@v3.22.4/mod.ts";

View File

@ -34,6 +34,7 @@ export const builtinLanguages: Record<string, Language> = {
"meta": StreamLanguage.define(yamlLanguage),
"yaml": StreamLanguage.define(yamlLanguage),
"template": StreamLanguage.define(yamlLanguage),
"block": StreamLanguage.define(yamlLanguage),
"embed": StreamLanguage.define(yamlLanguage),
"data": StreamLanguage.define(yamlLanguage),
"toc": StreamLanguage.define(yamlLanguage),

15
common/query_functions.ts Normal file
View 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);
},
};
}

View File

@ -16,7 +16,7 @@ export async function ensureSpaceIndex(ds: DataStore, system: System<any>) {
if (currentIndexVersion !== desiredIndexVersion && !indexOngoing) {
console.info("Performing a full space reindex, this could take a while...");
indexOngoing = true;
await system.loadedPlugs.get("index")!.invoke("reindexSpace", []);
await system.invokeFunction("index.reindexSpace", []);
console.info("Full space index complete.");
await markFullSpaceIndexComplete(ds);
indexOngoing = false;

View File

@ -40,6 +40,15 @@ export function handlebarHelpers() {
nextWeek.setDate(nextWeek.getDate() + 7);
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) {
if (v1 === v2) {
return options.fn(this);

View File

@ -10,14 +10,22 @@ export function handlebarsSyscalls(): SysCallMapping {
obj: any,
globals: Record<string, any> = {},
): string => {
const templateFn = Handlebars.compile(
template,
{ noEscape: true },
);
return templateFn(obj, {
helpers: handlebarHelpers(),
data: globals,
});
return renderHandlebarsTemplate(template, obj, globals);
},
};
}
export function renderHandlebarsTemplate(
template: string,
obj: any,
globals: Record<string, any>,
) {
const templateFn = Handlebars.compile(
template,
{ noEscape: true },
);
return templateFn(obj, {
helpers: handlebarHelpers(),
data: globals,
});
}

View File

@ -1,8 +1,9 @@
import { SETTINGS_TEMPLATE } from "./settings_template.ts";
import { SETTINGS_TEMPLATE } from "./PAGE_TEMPLATES.ts";
import { YAML } from "./deps.ts";
import { SpacePrimitives } from "./spaces/space_primitives.ts";
import { expandPropertyNames } from "$sb/lib/json.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.
@ -88,14 +89,7 @@ export async function ensureSettingsAndIndex(
);
await space.writeFile(
"index.md",
new TextEncoder().encode(
`Hello! And welcome to your brand new SilverBullet space!
\`\`\`template
page: "[[!silverbullet.md/Getting Started]]"
\`\`\`
`,
),
new TextEncoder().encode(INDEX_TEMPLATE),
);
}
}

View File

@ -19,6 +19,7 @@
"preact": "https://esm.sh/preact@10.11.1",
"$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"
}
}

View File

@ -11,6 +11,12 @@ export const builtinFunctions: FunctionMap = {
min(...args: number[]) {
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) {
return JSON.stringify(obj);
},

View File

@ -191,7 +191,10 @@ export function nodeAtPos(tree: ParseTree, pos: number): ParseTree | null {
}
// Turn ParseTree back into text
export function renderToText(tree: ParseTree): string {
export function renderToText(tree?: ParseTree): string {
if (!tree) {
return "";
}
const pieces: string[] = [];
if (tree.text !== undefined) {
return tree.text;

View File

@ -39,6 +39,16 @@ export function navigate(
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> {
return syscall("editor.reloadPage");
}
@ -47,6 +57,10 @@ export function reloadUI(): Promise<void> {
return syscall("editor.reloadUI");
}
export function reloadSettingsAndCommands(): Promise<void> {
return syscall("editor.reloadSettingsAndCommands");
}
export function openUrl(url: string, existingWindow = false): Promise<void> {
return syscall("editor.openUrl", url, existingWindow);
}

View File

@ -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> {
return this.syscall({}, name, args);
}
@ -90,7 +108,7 @@ export class System<HookT> extends EventEmitter<SystemEvents<HookT>> {
}
if (ctx.plug) {
// 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) {
throw new Error(
`Plug ${ctx.plug} not found while attempting to invoke ${name}}`,

View File

@ -17,8 +17,14 @@ export async function pageComplete(completeEvent: CompleteEvent) {
/render\s+\[\[|page:\s*["']\[\[/.test(
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];
if (prefix.startsWith("!")) {
// Federation prefix, let's first see if we're matching anything from federation that is locally synced

View File

@ -15,6 +15,27 @@ functions:
command:
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
deletePage:
path: "./page.ts:deletePage"
@ -35,6 +56,11 @@ functions:
events:
- editor:complete
reloadSettingsAndCommands:
path: editor.ts:reloadSettingsAndCommands
command:
name: "System: Reload Settings and Commands"
# Navigation
linkNavigate:
path: "./navigate.ts:linkNavigate"

View File

@ -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() {
let darkMode = await clientStore.get("darkMode");
darkMode = !darkMode;
@ -34,3 +46,8 @@ export async function moveToPosCommand() {
export async function customFlashMessage(_def: any, message: string) {
await editor.flashNotification(message);
}
export async function reloadSettingsAndCommands() {
await editor.reloadSettingsAndCommands();
await editor.flashNotification("Reloaded settings and commands");
}

View File

@ -14,15 +14,20 @@ export async function deletePage() {
await space.deletePage(pageName);
}
export async function copyPage(_def: any, predefinedNewName: string) {
const oldName = await editor.getCurrentPage();
let suggestedName = predefinedNewName || oldName;
export async function copyPage(
_def: any,
sourcePage?: string,
toName?: string,
) {
const currentPage = await editor.getCurrentPage();
const fromName = sourcePage || currentPage;
let suggestedName = toName || fromName;
if (isFederationPath(oldName)) {
const pieces = oldName.split("/");
if (isFederationPath(fromName)) {
const pieces = fromName.split("/");
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) {
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");
await space.writePage(newName, text);
console.log("Navigating to new page");
await editor.navigate(newName);
if (currentPage === fromName) {
// If we're copying the current page, navigate there
console.log("Navigating to new page");
await editor.navigate(newName);
} else {
// Otherwise just notify of success
await editor.flashNotification("Page copied successfully");
}
}

View File

@ -28,3 +28,9 @@ functions:
pageNamespace:
pattern: "!.+"
operation: getFileMeta
# Library management
importLibraryCommand:
path: library.ts:importLibraryCommand
command:
name: "Library: Import"

View File

@ -117,7 +117,7 @@ export async function cacheFileListing(uri: string): Promise<FileMeta[]> {
export async function readFile(
name: string,
): Promise<{ data: Uint8Array; meta: FileMeta } | undefined> {
): Promise<{ data: Uint8Array; meta: FileMeta }> {
const url = federatedPathToUrl(name);
console.log("Fetching federated file", url);
const r = await nativeFetch(url, {

View 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!");
}

View File

@ -60,7 +60,7 @@ export async function clearIndex(): Promise<void> {
console.log("Deleted", allKeys.length, "keys from the index");
}
// ENTITIES API
// OBJECTS API
/**
* Indexes entities in the data store

View File

@ -80,11 +80,7 @@ export const builtins: Record<string, Record<string, string>> = {
page: "!string",
pageName: "string",
pos: "!number",
type: "string",
trigger: "string",
where: "string",
priority: "number",
enabled: "boolean",
hooks: "hooksSpec",
},
};

View File

@ -162,17 +162,17 @@ functions:
# Template Widgets
renderTemplateWidgetsTop:
path: template_widget.ts:renderTemplateWidgets
path: widget.ts:renderTemplateWidgets
env: client
panelWidget: top
renderTemplateWidgetsBottom:
path: template_widget.ts:renderTemplateWidgets
path: widget.ts:renderTemplateWidgets
env: client
panelWidget: bottom
refreshWidgets:
path: template_widget.ts:refreshWidgets
path: widget.ts:refreshWidgets
lintYAML:
path: lint.ts:lintYAML

View File

@ -41,6 +41,10 @@ export async function widget(
return false;
});
if (headers.length === 0) {
return null;
}
if (config.minHeaders && headers.length < config.minHeaders) {
// Not enough headers, not showing TOC
return null;

View File

@ -7,9 +7,9 @@ import {
} from "$sb/silverbullet-syscall/mod.ts";
import { parseTreeToAST, renderToText } from "$sb/lib/tree.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 { TemplateObject } from "../template/types.ts";
import { TemplateObject, WidgetConfig } from "../template/types.ts";
import { expressionToKvQueryExpression } from "$sb/lib/parse-query.ts";
import { evalQueryExpression } from "$sb/lib/query.ts";
import { renderTemplate } from "../template/plug_api.ts";
@ -24,35 +24,39 @@ export async function renderTemplateWidgets(side: "top" | "bottom"): Promise<
CodeWidgetContent | null
> {
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 frontmatter = await extractFrontmatter(parsedMd);
const allFrontMatterTemplates = await queryObjects<TemplateObject>(
pageMeta = { ...pageMeta, ...frontmatter };
const blockTemplates = await queryObjects<TemplateObject>(
"template",
{
// where type = "widget:X" and enabled != false
filter: ["and", ["=", ["attr", "type"], ["string", `widget:${side}`]], [
"!=",
["attr", "enabled"],
["boolean", false],
]],
orderBy: [{ expr: ["attr", "priority"], desc: false }],
// where hooks.top/bottom exists
filter: ["attr", ["attr", "hooks"], side],
orderBy: [{
// order by hooks.top/bottom.order asc
expr: ["attr", ["attr", ["attr", "hooks"], side], "order"],
desc: false,
}],
},
);
// console.log(`Found the following ${side} templates`, blockTemplates);
const templateBits: string[] = [];
// Strategy: walk through all matching templates, evaluate the 'where' expression, and pick the first one that matches
for (const template of allFrontMatterTemplates) {
if (!template.where) {
for (const template of blockTemplates) {
if (!template.hooks) {
console.warn(
"Skipping template",
"No hooks specified for template",
template.ref,
"because it has no 'where' expression",
"this should never happen",
);
continue;
}
const blockDef = WidgetConfig.parse(template.hooks[side]!);
const exprAST = parseTreeToAST(
await language.parseLanguage("expression", template.where!),
await language.parseLanguage("expression", blockDef.where!),
);
const parsedExpression = expressionToKvQueryExpression(exprAST[1]);
if (evalQueryExpression(parsedExpression, pageMeta)) {
@ -68,11 +72,11 @@ export async function renderTemplateWidgets(side: "top" | "bottom"): Promise<
rewritePageRefs(parsedMarkdown, template.ref);
renderedTemplate = renderToText(parsedMarkdown);
// console.log("Rendering template", template.ref, renderedTemplate);
templateBits.push(renderedTemplate.trim());
}
}
const summaryText = templateBits.join("\n");
// console.log("Rendered", summaryText);
return {
markdown: summaryText,
buttons: [

View File

@ -54,6 +54,7 @@ export async function expandCodeWidgets(
// 'not found' is to be expected (no code widget configured for this language)
// Every other error should probably be reported
if (!e.message.includes("not found")) {
console.trace();
console.error("Error rendering code", e.message);
}
}

View File

@ -330,6 +330,13 @@ function render(
const command = t.children![1].children![0].text!;
let commandText = command;
const aliasNode = findNodeOfType(t, "CommandLinkAlias");
const argsNode = findNodeOfType(t, "CommandLinkArgs");
let args: any = [];
if (argsNode) {
args = JSON.parse(`[${argsNode.children![0].text!}]`);
}
if (aliasNode) {
commandText = aliasNode.children![0].text!;
}
@ -337,7 +344,7 @@ function render(
return {
name: "button",
attrs: {
"data-onclick": JSON.stringify(["command", command]),
"data-onclick": JSON.stringify(["command", command, args]),
},
body: commandText,
};

91
plugs/query/lint.ts Normal file
View 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];
}

View File

@ -1,20 +1,19 @@
name: query
functions:
queryWidget:
path: query.ts:widget
path: widget.ts:widget
codeWidget: query
renderMode: markdown
# Query widget buttons
editButton:
path: widget.ts:editButton
lintQuery:
path: query.ts:lintQuery
path: lint.ts:lintQuery
events:
- editor:lint
templateWidget:
path: template.ts:widget
codeWidget: template
renderMode: markdown
queryComplete:
path: complete.ts:queryComplete
events:
@ -31,26 +30,3 @@ functions:
name: "Live Queries and Templates: Refresh All"
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: "[[|^|]]"
```

View File

@ -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];
}

View File

@ -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() {
codeWidget.refreshAll();

View File

@ -23,15 +23,6 @@ functions:
updateTaskState:
path: task.ts:updateTaskState
turnIntoTask:
redirect: template.applyLineReplace
slashCommand:
name: task
description: Turn into task
match: "^(\\s*)[\\-\\*]?\\s*(\\[[ xX]\\])?\\s*"
replace: "$1* [ ] "
indexTasks:
path: "./task.ts:indexTasks"
events:

View File

@ -1,114 +0,0 @@
import { CompleteEvent, SlashCompletion } from "$sb/app_event.ts";
import { PageMeta } from "$sb/types.ts";
import { editor, events, markdown, space } from "$sb/syscalls.ts";
import type {
AttributeCompleteEvent,
AttributeCompletion,
} from "../index/attributes.ts";
import { queryObjects } from "../index/plug_api.ts";
import { TemplateObject } from "./types.ts";
import { loadPageObject } from "./template.ts";
import { renderTemplate } from "./api.ts";
import { prepareFrontmatterDispatch } from "$sb/lib/frontmatter.ts";
import { buildHandebarOptions } from "./util.ts";
export async function templateVariableComplete(completeEvent: CompleteEvent) {
const match = /\{\{([\w@]*)$/.exec(completeEvent.linePrefix);
if (!match) {
return null;
}
const handlebarOptions = buildHandebarOptions({ name: "" } as PageMeta);
let allCompletions: any[] = Object.keys(handlebarOptions.helpers).map(
(name) => ({ label: name, detail: "helper" }),
);
allCompletions = allCompletions.concat(
Object.keys(handlebarOptions.data).map((key) => ({
label: `@${key}`,
detail: "global variable",
})),
);
const completions = (await events.dispatchEvent(
`attribute:complete:_`,
{
source: "",
prefix: match[1],
} as AttributeCompleteEvent,
)).flat() as AttributeCompletion[];
allCompletions = allCompletions.concat(
attributeCompletionsToCMCompletion(completions),
);
return {
from: completeEvent.pos - match[1].length,
options: allCompletions,
};
}
export async function templateSlashComplete(
completeEvent: CompleteEvent,
): Promise<SlashCompletion[]> {
const allTemplates = await queryObjects<TemplateObject>("template", {
// Only return templates that have a trigger and are not expliclty disabled
filter: ["and", ["attr", "trigger"], ["!=", ["attr", "enabled"], [
"boolean",
false,
]]],
}, 5);
return allTemplates.map((template) => ({
label: template.trigger!,
detail: "template",
templatePage: template.ref,
pageName: completeEvent.pageName,
invoke: "template.insertSlashTemplate",
}));
}
export async function insertSlashTemplate(slashCompletion: SlashCompletion) {
const pageObject = await loadPageObject(slashCompletion.pageName);
const templateText = await space.readPage(slashCompletion.templatePage);
let { renderedFrontmatter, text } = await renderTemplate(
templateText,
pageObject,
);
let cursorPos = await editor.getCursor();
if (renderedFrontmatter) {
renderedFrontmatter = renderedFrontmatter.trim();
const pageText = await editor.getText();
const tree = await markdown.parseMarkdown(pageText);
const dispatch = await prepareFrontmatterDispatch(
tree,
renderedFrontmatter,
);
if (cursorPos === 0) {
dispatch.selection = { anchor: renderedFrontmatter.length + 9 };
}
await editor.dispatch(dispatch);
}
cursorPos = await editor.getCursor();
const carretPos = text.indexOf("|^|");
text = text.replace("|^|", "");
await editor.insertAtCursor(text);
if (carretPos !== -1) {
await editor.moveCursor(cursorPos + carretPos);
}
}
export function attributeCompletionsToCMCompletion(
completions: AttributeCompletion[],
) {
return completions.map(
(completion) => ({
label: completion.name,
detail: `${completion.attributeType} (${completion.source})`,
type: "attribute",
}),
);
}

43
plugs/template/lint.ts Normal file
View File

@ -0,0 +1,43 @@
import { LintEvent } from "$sb/app_event.ts";
import { LintDiagnostic } from "$sb/types.ts";
import { findNodeOfType } from "$sb/lib/tree.ts";
import { FrontmatterConfig } from "./types.ts";
import { extractFrontmatter } from "$sb/lib/frontmatter.ts";
export async function lintTemplateFrontmatter(
{ tree }: LintEvent,
): Promise<LintDiagnostic[]> {
const diagnostics: LintDiagnostic[] = [];
const frontmatter = await extractFrontmatter(tree);
// Just looking this up again for the purposes of error reporting
const frontmatterNode = findNodeOfType(tree, "FrontMatterCode")!;
if (!frontmatter.tags?.includes("template")) {
return [];
}
try {
// Just parse to make sure it's valid
FrontmatterConfig.parse(frontmatter);
} catch (e: any) {
if (e.message.startsWith("[")) { // We got a zod error
const zodErrors = JSON.parse(e.message);
for (const zodError of zodErrors) {
console.log("Zod validation error", zodError);
diagnostics.push({
from: frontmatterNode.from!,
to: frontmatterNode.to!,
message: `Attribute ${zodError.path.join(".")}: ${zodError.message}`,
severity: "error",
});
}
} else {
diagnostics.push({
from: frontmatterNode.from!,
to: frontmatterNode.to!,
message: e.message,
severity: "error",
});
}
}
return diagnostics;
}

226
plugs/template/page.ts Normal file
View File

@ -0,0 +1,226 @@
import { editor, handlebars, space } from "$sb/syscalls.ts";
import { PageMeta } from "$sb/types.ts";
import { getObjectByRef, queryObjects } from "../index/plug_api.ts";
import { FrontmatterConfig, TemplateObject } from "./types.ts";
import { renderTemplate } from "./api.ts";
export async function newPageCommand(
_cmdDef: any,
templateName?: string,
askName = true,
) {
if (!templateName) {
const allPageTemplates = await listPageTemplates();
// console.log("All page templates", allPageTemplates);
const selectedTemplate = await selectPageTemplate(allPageTemplates);
if (!selectedTemplate) {
return;
}
templateName = selectedTemplate.ref;
}
console.log("Selected template", templateName);
await instantiatePageTemplate(templateName!, undefined, askName);
}
function listPageTemplates() {
return queryObjects<TemplateObject>("template", {
// where hooks.newPage exists
filter: ["attr", ["attr", "hooks"], "newPage"],
});
}
// Invoked when a new page is created
export async function newPage(pageName: string) {
console.log("Asked to setup a new page for", pageName);
const allPageTemplatesMatchingPrefix = (await listPageTemplates()).filter(
(templateObject) => {
const forPrefix = templateObject.hooks?.newPage?.forPrefix;
return forPrefix && pageName.startsWith(forPrefix);
},
);
// console.log("Matching templates", allPageTemplatesMatchingPrefix);
if (allPageTemplatesMatchingPrefix.length === 0) {
// No matching templates, that's ok, we'll just start with an empty page, so let's just return
return;
}
if (allPageTemplatesMatchingPrefix.length === 1) {
// Only one matching template, let's use it
await instantiatePageTemplate(
allPageTemplatesMatchingPrefix[0].ref,
pageName,
false,
);
} else {
// Let's offer a choice
const selectedTemplate = await selectPageTemplate(
allPageTemplatesMatchingPrefix,
);
if (!selectedTemplate) {
// No choice made? We'll start out empty
return;
}
await instantiatePageTemplate(
selectedTemplate.ref,
pageName,
false,
);
}
}
function selectPageTemplate(options: TemplateObject[]) {
return editor.filterBox(
"Page template",
options.map((templateObj) => {
const niceName = templateObj.ref.split("/").pop()!;
return {
...templateObj,
description: templateObj.description || templateObj.ref,
name: templateObj.displayName || niceName,
};
}),
`Select the template to create a new page from`,
);
}
async function instantiatePageTemplate(
templateName: string,
intoCurrentPage: string | undefined,
askName: boolean,
) {
const templateText = await space.readPage(templateName!);
console.log(
"Instantiating page template",
templateName,
intoCurrentPage,
askName,
);
const tempPageMeta: PageMeta = {
tag: "page",
ref: "",
name: "",
created: "",
lastModified: "",
perm: "rw",
};
// Just used to extract the frontmatter
const { frontmatter } = await renderTemplate(
templateText,
tempPageMeta,
);
let frontmatterConfig: FrontmatterConfig;
try {
frontmatterConfig = FrontmatterConfig.parse(frontmatter!);
} catch (e: any) {
await editor.flashNotification(
`Error parsing template frontmatter for ${templateName}: ${e.message}`,
);
return;
}
const newPageConfig = frontmatterConfig.hooks!.newPage!;
let pageName: string | undefined = intoCurrentPage ||
await replaceTemplateVars(
newPageConfig.suggestedName || "",
tempPageMeta,
);
if (!intoCurrentPage && askName && newPageConfig.confirmName !== false) {
pageName = await editor.prompt(
"Name of new page",
await replaceTemplateVars(
newPageConfig.suggestedName || "",
tempPageMeta,
),
);
if (!pageName) {
return;
}
}
tempPageMeta.name = pageName;
if (!intoCurrentPage) {
// Check if page exists, but only if we're not forcing the name (which only happens when we know that we're creating a new page already)
try {
// Fails if doesn't exist
await space.getPageMeta(pageName);
// So, page exists
if (newPageConfig.openIfExists) {
console.log("Page already exists, navigating there");
await editor.navigate(pageName);
return;
}
// let's warn
if (
!await editor.confirm(
`Page ${pageName} already exists, are you sure you want to override it?`,
)
) {
// Just navigate there without instantiating
return editor.navigate(pageName);
}
} catch {
// The preferred scenario, let's keep going
}
}
const { text: pageText, renderedFrontmatter } = await renderTemplate(
templateText,
tempPageMeta,
);
let fullPageText = renderedFrontmatter
? "---\n" + renderedFrontmatter + "---\n" + pageText
: pageText;
const carretPos = fullPageText.indexOf("|^|");
fullPageText = fullPageText.replace("|^|", "");
if (intoCurrentPage) {
await editor.insertAtCursor(fullPageText);
if (carretPos !== -1) {
await editor.moveCursor(carretPos);
}
} else {
await space.writePage(
pageName,
fullPageText,
);
await editor.navigate(pageName, carretPos !== -1 ? carretPos : undefined);
}
}
export async function loadPageObject(pageName?: string): Promise<PageMeta> {
if (!pageName) {
return {
ref: "",
name: "",
tags: ["page"],
lastModified: "",
created: "",
} as PageMeta;
}
return (await getObjectByRef<PageMeta>(
pageName,
"page",
pageName,
)) || {
ref: pageName,
name: pageName,
tags: ["page"],
lastModified: "",
created: "",
} as PageMeta;
}
export function replaceTemplateVars(
s: string,
pageMeta: PageMeta,
): Promise<string> {
return handlebars.renderTemplate(s, {}, { page: pageMeta });
}

155
plugs/template/snippet.ts Normal file
View File

@ -0,0 +1,155 @@
import { CompleteEvent, SlashCompletion } from "$sb/app_event.ts";
import { editor, markdown, space } from "$sb/syscalls.ts";
import type { AttributeCompletion } from "../index/attributes.ts";
import { queryObjects } from "../index/plug_api.ts";
import { TemplateObject } from "./types.ts";
import { loadPageObject } from "./page.ts";
import { renderTemplate } from "./api.ts";
import { prepareFrontmatterDispatch } from "$sb/lib/frontmatter.ts";
import { SnippetConfig } from "./types.ts";
import { snippet } from "@codemirror/autocomplete";
export async function snippetSlashComplete(
completeEvent: CompleteEvent,
): Promise<SlashCompletion[]> {
const allTemplates = await queryObjects<TemplateObject>("template", {
// where hooks.snippet.slashCommand exists
filter: ["attr", ["attr", ["attr", "hooks"], "snippet"], "slashCommand"],
}, 5);
return allTemplates.map((template) => {
const snippetTemplate = template.hooks!.snippet!;
return {
label: snippetTemplate.slashCommand,
detail: template.description,
templatePage: template.ref,
pageName: completeEvent.pageName,
invoke: "template.insertSnippetTemplate",
};
});
}
export async function insertSnippetTemplate(slashCompletion: SlashCompletion) {
const pageObject = await loadPageObject(
slashCompletion.pageName,
);
const templateText = await space.readPage(slashCompletion.templatePage);
let { renderedFrontmatter, text: replacementText, frontmatter } =
await renderTemplate(
templateText,
pageObject,
);
let snippetTemplate: SnippetConfig;
try {
snippetTemplate = SnippetConfig.parse(frontmatter.hooks!.snippet!);
} catch (e: any) {
console.error(
`Invalid template configuration for ${slashCompletion.templatePage}:`,
e.message,
);
await editor.flashNotification(
`Invalid template configuration for ${slashCompletion.templatePage}, won't insert snippet`,
"error",
);
return;
}
let cursorPos = await editor.getCursor();
if (renderedFrontmatter) {
renderedFrontmatter = renderedFrontmatter.trim();
const pageText = await editor.getText();
const tree = await markdown.parseMarkdown(pageText);
const dispatch = await prepareFrontmatterDispatch(
tree,
renderedFrontmatter,
);
if (cursorPos === 0) {
dispatch.selection = { anchor: renderedFrontmatter.length + 9 };
}
await editor.dispatch(dispatch);
// update cursor position
cursorPos = await editor.getCursor();
}
if (snippetTemplate.insertAt) {
switch (snippetTemplate.insertAt) {
case "page-start":
await editor.moveCursor(0);
break;
case "page-end":
await editor.moveCursor((await editor.getText()).length);
break;
case "line-start": {
const pageText = await editor.getText();
let startOfLine = cursorPos;
while (startOfLine > 0 && pageText[startOfLine - 1] !== "\n") {
startOfLine--;
}
await editor.moveCursor(startOfLine);
break;
}
case "line-end": {
const pageText = await editor.getText();
let endOfLine = cursorPos;
while (endOfLine < pageText.length && pageText[endOfLine] !== "\n") {
endOfLine++;
}
await editor.moveCursor(endOfLine);
break;
}
default:
// Deliberate no-op
}
}
cursorPos = await editor.getCursor();
if (snippetTemplate.matchRegex) {
const pageText = await editor.getText();
// Regex matching mode
const matchRegex = new RegExp(snippetTemplate.matchRegex);
let startOfLine = cursorPos;
while (startOfLine > 0 && pageText[startOfLine - 1] !== "\n") {
startOfLine--;
}
let currentLine = pageText.slice(startOfLine, cursorPos);
const emptyLine = !currentLine;
currentLine = currentLine.replace(matchRegex, replacementText);
await editor.dispatch({
changes: {
from: startOfLine,
to: cursorPos,
insert: currentLine,
},
selection: emptyLine
? {
anchor: startOfLine + currentLine.length,
}
: undefined,
});
} else {
const carretPos = replacementText.indexOf("|^|");
replacementText = replacementText.replace("|^|", "");
await editor.insertAtCursor(replacementText);
if (carretPos !== -1) {
await editor.moveCursor(cursorPos + carretPos);
}
}
}
export function attributeCompletionsToCMCompletion(
completions: AttributeCompletion[],
) {
return completions.map(
(completion) => ({
label: completion.name,
detail: `${completion.attributeType} (${completion.source})`,
type: "attribute",
}),
);
}

View File

@ -3,135 +3,51 @@ functions:
# API
renderTemplate:
path: api.ts:renderTemplate
cleanTemplate:
path: api.ts:cleanTemplate
# Used by various slash commands
insertTemplateText:
path: template.ts:insertTemplateText
# Indexing
indexTemplate:
path: ./index.ts:indexTemplate
path: index.ts:indexTemplate
events:
# Special event only triggered for template pages
- page:indexTemplate
# Completion
templateSlashCommand:
path: ./complete.ts:templateSlashComplete
path: snippet.ts:snippetSlashComplete
events:
- slash:complete
insertSlashTemplate:
path: ./complete.ts:insertSlashTemplate
insertSnippetTemplate:
path: snippet.ts:insertSnippetTemplate
handlebarHelperComplete:
path: ./complete.ts:templateVariableComplete
path: var.ts:templateVariableComplete
events:
- editor:complete
applyLineReplace:
path: ./template.ts:applyLineReplace
insertFrontMatter:
redirect: insertTemplateText
slashCommand:
name: frontmatter
description: Insert page frontmatter
value: |
---
|^|
---
makeH1:
redirect: applyLineReplace
slashCommand:
name: h1
description: Turn line into h1 header
match: "^#*\\s*"
replace: "# "
makeH2:
redirect: applyLineReplace
slashCommand:
name: h2
description: Turn line into h2 header
match: "^#*\\s*"
replace: "## "
makeH3:
redirect: applyLineReplace
slashCommand:
name: h3
description: Turn line into h3 header
match: "^#*\\s*"
replace: "### "
makeH4:
redirect: applyLineReplace
slashCommand:
name: h4
description: Turn line into h4 header
match: "^#*\\s*"
replace: "#### "
insertCodeBlock:
redirect: insertTemplateText
slashCommand:
name: code
description: Insert fenced code block
value: |
```|^|
# Widget
templateWidget: # Legacy
path: template_block.ts:widget
codeWidget: template
renderMode: markdown
```
insertHRTemplate:
redirect: insertTemplateText
slashCommand:
name: hr
description: Insert a horizontal rule
value: "---"
insertTable:
redirect: insertTemplateText
slashCommand:
name: table
description: Insert a table
boost: -1 # Low boost because it's likely not very commonly used
value: |
| Header A | Header B |
|----------|----------|
| Cell A|^| | Cell B |
quickNoteCommand:
path: ./template.ts:quickNoteCommand
command:
name: "Quick Note"
key: "Alt-Shift-n"
priority: 3
dailyNoteCommand:
path: ./template.ts:dailyNoteCommand
command:
name: "Open Daily Note"
key: "Alt-Shift-d"
weeklyNoteCommand:
path: ./template.ts:weeklyNoteCommand
command:
name: "Open Weekly Note"
key: "Alt-Shift-w"
# API invoked when a new page is created
newPage:
path: page.ts:newPage
# Commands
newPageCommand:
path: ./template.ts:newPageCommand
path: page.ts:newPageCommand
command:
name: "Page: From Template"
key: "Alt-Shift-t"
insertTodayCommand:
path: "./template.ts:insertTemplateText"
slashCommand:
name: today
description: Insert today's date
value: "{{today}}"
insertTomorrowCommand:
path: "./template.ts:insertTemplateText"
slashCommand:
name: tomorrow
description: Insert tomorrow's date
value: "{{tomorrow}}"
# Lint
lintTemplateFrontmatter:
path: lint.ts:lintTemplateFrontmatter
events:
- editor:lint

View File

@ -1,274 +0,0 @@
import { editor, handlebars, space } from "$sb/syscalls.ts";
import { niceDate, niceTime } from "$sb/lib/dates.ts";
import { readSettings } from "$sb/lib/settings_page.ts";
import { cleanPageRef } from "$sb/lib/resolve.ts";
import { PageMeta } from "$sb/types.ts";
import { getObjectByRef, queryObjects } from "../index/plug_api.ts";
import { TemplateObject } from "./types.ts";
import { renderTemplate } from "./api.ts";
export async function newPageCommand(
_cmdDef: any,
templateName?: string,
askName = true,
) {
if (!templateName) {
const allPageTemplates = await queryObjects<TemplateObject>("template", {
// Only return templates that have a trigger
filter: ["=", ["attr", "type"], ["string", "page"]],
});
const selectedTemplate = await editor.filterBox(
"Page template",
allPageTemplates
.map((pageMeta) => ({
...pageMeta,
name: pageMeta.displayName || pageMeta.ref,
})),
`Select the template to create a new page from (listing any page tagged with <tt>#template</tt> and 'page' set as 'type')`,
);
if (!selectedTemplate) {
return;
}
templateName = selectedTemplate.ref;
}
console.log("Selected template", templateName);
const templateText = await space.readPage(templateName!);
const tempPageMeta: PageMeta = {
tag: "page",
ref: "",
name: "",
created: "",
lastModified: "",
perm: "rw",
};
// Just used to extract the frontmatter
const { frontmatter } = await renderTemplate(
templateText,
tempPageMeta,
);
let pageName: string | undefined = await replaceTemplateVars(
frontmatter?.pageName || "",
tempPageMeta,
);
if (askName) {
pageName = await editor.prompt(
"Name of new page",
await replaceTemplateVars(frontmatter?.pageName || "", tempPageMeta),
);
if (!pageName) {
return;
}
}
tempPageMeta.name = pageName;
try {
// Fails if doesn't exist
await space.getPageMeta(pageName);
// So, page exists, let's warn
if (
!await editor.confirm(
`Page ${pageName} already exists, are you sure you want to override it?`,
)
) {
// Just navigate there without instantiating
return editor.navigate(pageName);
}
} catch {
// The preferred scenario, let's keep going
}
const { text: pageText, renderedFrontmatter } = await renderTemplate(
templateText,
tempPageMeta,
);
let fullPageText = renderedFrontmatter
? "---\n" + renderedFrontmatter + "---\n" + pageText
: pageText;
const carretPos = fullPageText.indexOf("|^|");
fullPageText = fullPageText.replace("|^|", "");
await space.writePage(
pageName,
fullPageText,
);
await editor.navigate(pageName, carretPos !== -1 ? carretPos : undefined);
}
export async function loadPageObject(pageName?: string): Promise<PageMeta> {
if (!pageName) {
return {
ref: "",
name: "",
tags: ["page"],
lastModified: "",
created: "",
} as PageMeta;
}
return (await getObjectByRef<PageMeta>(
pageName,
"page",
pageName,
)) || {
ref: pageName,
name: pageName,
tags: ["page"],
lastModified: "",
created: "",
} as PageMeta;
}
export function replaceTemplateVars(
s: string,
pageMeta: PageMeta,
): Promise<string> {
return handlebars.renderTemplate(s, {}, { page: pageMeta });
}
export async function quickNoteCommand() {
const { quickNotePrefix } = await readSettings({
quickNotePrefix: "📥 ",
});
const date = niceDate(new Date());
const time = niceTime(new Date());
const pageName = `${quickNotePrefix}${date} ${time}`;
await editor.navigate(pageName);
}
export async function dailyNoteCommand() {
const { dailyNoteTemplate, dailyNotePrefix } = await readSettings({
dailyNoteTemplate: "[[template/page/Daily Note]]",
dailyNotePrefix: "📅 ",
});
const date = niceDate(new Date());
const pageName = `${dailyNotePrefix}${date}`;
let carretPos = 0;
try {
await space.getPageMeta(pageName);
} catch {
// Doesn't exist, let's create
let dailyNoteTemplateText = "";
try {
dailyNoteTemplateText = await space.readPage(
cleanPageRef(dailyNoteTemplate),
);
carretPos = dailyNoteTemplateText.indexOf("|^|");
if (carretPos === -1) {
carretPos = 0;
}
dailyNoteTemplateText = dailyNoteTemplateText.replace("|^|", "");
} catch {
console.warn(`No daily note template found at ${dailyNoteTemplate}`);
}
await space.writePage(
pageName,
await replaceTemplateVars(dailyNoteTemplateText, {
tag: "page",
ref: pageName,
name: pageName,
created: "",
lastModified: "",
perm: "rw",
}),
);
}
await editor.navigate(pageName, carretPos);
}
function getWeekStartDate(monday = false) {
const d = new Date();
const day = d.getDay();
let diff = d.getDate() - day;
if (monday) {
diff += day == 0 ? -6 : 1;
}
return new Date(d.setDate(diff));
}
export async function weeklyNoteCommand() {
const { weeklyNoteTemplate, weeklyNotePrefix, weeklyNoteMonday } =
await readSettings({
weeklyNoteTemplate: "[[template/page/Weekly Note]]",
weeklyNotePrefix: "🗓️ ",
weeklyNoteMonday: false,
});
let weeklyNoteTemplateText = "";
try {
weeklyNoteTemplateText = await space.readPage(
cleanPageRef(weeklyNoteTemplate),
);
} catch {
console.warn(`No weekly note template found at ${weeklyNoteTemplate}`);
}
const date = niceDate(getWeekStartDate(weeklyNoteMonday));
const pageName = `${weeklyNotePrefix}${date}`;
if (weeklyNoteTemplateText) {
try {
await space.getPageMeta(pageName);
} catch {
// Doesn't exist, let's create
await space.writePage(
pageName,
await replaceTemplateVars(weeklyNoteTemplateText, {
name: pageName,
ref: pageName,
tag: "page",
created: "",
lastModified: "",
perm: "rw",
}),
);
}
await editor.navigate(pageName);
} else {
await editor.navigate(pageName);
}
}
export async function insertTemplateText(cmdDef: any) {
const cursorPos = await editor.getCursor();
const page = await editor.getCurrentPage();
const pageMeta = await loadPageObject(page);
let templateText: string = cmdDef.value;
const carretPos = templateText.indexOf("|^|");
templateText = templateText.replace("|^|", "");
templateText = await replaceTemplateVars(templateText, pageMeta);
await editor.insertAtCursor(templateText);
if (carretPos !== -1) {
await editor.moveCursor(cursorPos + carretPos);
}
}
export async function applyLineReplace(cmdDef: any) {
const cursorPos = await editor.getCursor();
const text = await editor.getText();
const matchRegex = new RegExp(cmdDef.match);
let startOfLine = cursorPos;
while (startOfLine > 0 && text[startOfLine - 1] !== "\n") {
startOfLine--;
}
let currentLine = text.slice(startOfLine, cursorPos);
const emptyLine = !currentLine;
currentLine = currentLine.replace(matchRegex, cmdDef.replace);
await editor.dispatch({
changes: {
from: startOfLine,
to: cursorPos,
insert: currentLine,
},
selection: emptyLine
? {
anchor: startOfLine + currentLine.length,
}
: undefined,
});
}

View File

@ -1,13 +1,13 @@
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 { renderTemplate } from "../template/plug_api.ts";
import { renderTemplate } from "./plug_api.ts";
import { renderToText } from "$sb/lib/tree.ts";
import { rewritePageRefs, rewritePageRefsInString } from "$sb/lib/resolve.ts";
import { performQuery } from "./query.ts";
import { performQuery } from "../query/widget.ts";
import { parseQuery } from "$sb/lib/parse-query.ts";
type TemplateConfig = {
type TemplateWidgetConfig = {
// Pull the template from a page
page?: string;
// Or use a string directly
@ -29,7 +29,7 @@ export async function widget(
const pageMeta: PageMeta = await loadPageObject(pageName);
try {
const config: TemplateConfig = await YAML.parse(bodyText);
const config: TemplateWidgetConfig = await YAML.parse(bodyText);
let templateText = config.template || "";
let templatePage = config.page;
if (templatePage) {
@ -41,7 +41,13 @@ export async function widget(
if (!templatePage) {
throw new Error("No template page specified");
}
templateText = await space.readPage(templatePage);
try {
templateText = await space.readPage(templatePage);
} catch (e: any) {
if (e.message === "Not found") {
throw new Error(`Template page ${templatePage} not found`);
}
}
}
let value: any;

View File

@ -1,17 +1,100 @@
import { ObjectValue } from "$sb/types.ts";
import { z, ZodEffects } from "zod";
export type TemplateFrontmatter = {
displayName?: string;
type?: "page";
export const CommandConfig = z.object({
command: z.string().optional(),
key: z.string().optional(),
mac: z.string().optional(),
});
export type CommandConfig = z.infer<typeof CommandConfig>;
/**
* Used for creating new pages using {[Page: From Template]} command
*/
export const NewPageConfig = refineCommand(
z.object({
// Suggested name for the new page, can use template placeholders
suggestedName: z.string().optional(),
// Suggest (or auto use) this template for a specific prefix
forPrefix: z.string().optional(),
// Confirm the name before creating
confirmName: z.boolean().optional(),
// If the page already exists, open it instead of creating a new one
openIfExists: z.boolean().optional(),
}).strict().merge(CommandConfig),
);
export type NewPageConfig = z.infer<typeof NewPageConfig>;
/**
* Represents a snippet
*/
export const SnippetConfig = refineCommand(
z.object({
slashCommand: z.string(), // trigger
// Regex match to apply (implicitly makes the body the regex replacement)
matchRegex: z.string().optional(),
insertAt: z.enum([
"cursor",
"line-start",
"line-end",
"page-start",
"page-end",
]).optional(), // defaults to cursor
}).strict().merge(CommandConfig),
);
/**
* Ensures that 'command' is present if either 'key' or 'mac' is present for a particular object
* @param o object to 'refine' with this constraint
* @returns
*/
function refineCommand<T extends typeof CommandConfig>(o: T): ZodEffects<T> {
return o.refine((data) => {
// Check if either 'key' or 'mac' is present
const hasKeyOrMac = data.key !== undefined || data.mac !== undefined;
// Ensure 'command' is present if either 'key' or 'mac' is present
return !hasKeyOrMac || data.command !== undefined;
}, {
message:
"Attribute 'command' is required when specifying a key binding via 'key' and/or 'mac'.",
});
}
export type SnippetConfig = z.infer<typeof SnippetConfig>;
export const WidgetConfig = z.object({
where: z.string(),
priority: z.number().optional(),
});
export type WidgetConfig = z.infer<typeof WidgetConfig>;
export const HooksConfig = z.object({
top: WidgetConfig.optional(),
bottom: WidgetConfig.optional(),
newPage: NewPageConfig.optional(),
snippet: SnippetConfig.optional(),
}).strict();
export type HooksConfig = z.infer<typeof HooksConfig>;
export const FrontmatterConfig = z.object({
// Used for matching in page navigator
displayName: z.string().optional(),
tags: z.union([z.string(), z.array(z.string())]).optional(),
// For use in the template selector slash commands and other avenues
description: z.string().optional(),
// Frontmatter can be encoded as an object (in which case we'll serialize it) or as a string
frontmatter?: Record<string, any> | string;
frontmatter: z.union([z.record(z.unknown()), z.string()]).optional(),
// Specific for slash templates
trigger?: string;
hooks: HooksConfig.optional(),
});
// Specific for frontmatter templates
where?: string; // expression (SB query style)
priority?: number; // When multiple templates match, the one with the highest priority is used
};
export type FrontmatterConfig = z.infer<typeof FrontmatterConfig>;
export type TemplateObject = ObjectValue<TemplateFrontmatter>;
export type TemplateObject = ObjectValue<FrontmatterConfig>;

44
plugs/template/var.ts Normal file
View File

@ -0,0 +1,44 @@
import { CompleteEvent } from "$sb/app_event.ts";
import { PageMeta } from "$sb/types.ts";
import { events } from "$sb/syscalls.ts";
import { buildHandebarOptions } from "./util.ts";
import {
AttributeCompleteEvent,
AttributeCompletion,
} from "../index/attributes.ts";
import { attributeCompletionsToCMCompletion } from "./snippet.ts";
export async function templateVariableComplete(completeEvent: CompleteEvent) {
const match = /\{\{([\w@]*)$/.exec(completeEvent.linePrefix);
if (!match) {
return null;
}
const handlebarOptions = buildHandebarOptions({ name: "" } as PageMeta);
let allCompletions: any[] = Object.keys(handlebarOptions.helpers).map(
(name) => ({ label: name, detail: "helper" }),
);
allCompletions = allCompletions.concat(
Object.keys(handlebarOptions.data).map((key) => ({
label: `@${key}`,
detail: "global variable",
})),
);
const completions = (await events.dispatchEvent(
`attribute:complete:_`,
{
source: "",
prefix: match[1],
} as AttributeCompleteEvent,
)).flat() as AttributeCompletion[];
allCompletions = allCompletions.concat(
attributeCompletionsToCMCompletion(completions),
);
return {
from: completeEvent.pos - match[1].length,
options: allCompletions,
};
}

View File

@ -405,7 +405,7 @@ export class HttpServer {
const args: string[] = body;
try {
const result = await spaceServer.system!.syscall(
{ plug: plugName },
{ plug: plugName === "_" ? undefined : plugName },
syscall,
args,
);

View File

@ -34,6 +34,8 @@ import { KVPrimitivesManifestCache } from "../plugos/manifest_cache.ts";
import { KvPrimitives } from "../plugos/lib/kv_primitives.ts";
import { ShellBackend } from "./shell_backend.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
// import {
@ -59,6 +61,7 @@ export class ServerSystem {
// denoKv!: Deno.Kv;
listInterval?: number;
ds!: DataStore;
allKnownPages = new Set<string>();
constructor(
private baseSpacePrimitives: SpacePrimitives,
@ -69,7 +72,10 @@ export class ServerSystem {
// Always needs to be invoked right after construction
async init(awaitIndex = false) {
this.ds = new DataStore(this.kvPrimitives);
this.ds = new DataStore(
this.kvPrimitives,
buildQueryFunctions(this.allKnownPages),
);
this.system = new System(
"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
const indexPromise = ensureSpaceIndex(this.ds, this.system);
if (awaitIndex) {

View File

@ -1,5 +1,6 @@
// Third party web dependencies
import {
Compartment,
CompletionContext,
CompletionResult,
EditorView,
@ -52,6 +53,8 @@ import {
markFullSpaceIndexComplete,
} from "../common/space_index.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 autoSaveInterval = 1000;
@ -71,6 +74,8 @@ declare global {
export class Client {
system!: ClientSystem;
editorView!: EditorView;
keyHandlerCompartment?: Compartment;
private pageNavigator!: PathPageNavigator;
private dbPrefix: string;
@ -136,7 +141,10 @@ export class Client {
`${this.dbPrefix}_state`,
);
await stateKvPrimitives.init();
this.stateDataStore = new DataStore(stateKvPrimitives);
this.stateDataStore = new DataStore(
stateKvPrimitives,
buildQueryFunctions(this.allKnownPages),
);
// Setup message queue
this.mq = new DataStoreMQ(this.stateDataStore);
@ -190,8 +198,7 @@ export class Client {
await this.system.init();
// Load settings
this.settings = await ensureSettingsAndIndex(localSpacePrimitives);
await this.loadSettings();
await this.loadCaches();
// 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);
}
async loadSettings() {
this.settings = await ensureSettingsAndIndex(this.space.spacePrimitives);
}
private async initSync() {
this.syncService.start();
@ -303,7 +314,7 @@ export class Client {
private initNavigator() {
this.pageNavigator = new PathPageNavigator(
cleanPageRef(this.settings.indexPage),
cleanPageRef(renderHandlebarsTemplate(this.settings.indexPage, {}, {})),
);
this.pageNavigator.subscribe(
@ -478,7 +489,12 @@ export class Client {
new EventedSpacePrimitives(
// 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 DataStoreSpacePrimitives(new DataStore(spaceKvPrimitives)),
new DataStoreSpacePrimitives(
new DataStore(
spaceKvPrimitives,
buildQueryFunctions(this.allKnownPages),
),
),
this.plugSpaceRemotePrimitives,
),
this.eventHook,
@ -487,7 +503,7 @@ export class Client {
// Run when a list of files has been retrieved
async () => {
if (!this.settings) {
this.settings = await ensureSettingsAndIndex(localSpacePrimitives!);
await this.loadSettings();
}
if (typeof this.settings?.spaceIgnore === "string") {
@ -547,11 +563,12 @@ export class Client {
"file:listed",
(allFiles: FileMeta[]) => {
// Update list of known pages
this.allKnownPages = new Set(
allFiles.filter((f) => f.name.endsWith(".md")).map((f) =>
f.name.slice(0, -3)
),
);
this.allKnownPages.clear();
allFiles.forEach((f) => {
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
this.ui.viewDispatch({ type: "start-navigate" });
this.ui.viewDispatch({ type: "start-navigate", mode });
this.updatePageListCache().catch(console.error);
}
@ -854,7 +871,9 @@ export class Client {
newWindow = false,
) {
if (!name) {
name = cleanPageRef(this.settings.indexPage);
name = cleanPageRef(
renderHandlebarsTemplate(this.settings.indexPage, {}, {}),
);
}
try {
@ -903,6 +922,7 @@ export class Client {
if (e.message.includes("Not found")) {
// Not found, new page
console.log("Page doesn't exist, creating new page:", pageName);
// Initialize page
doc = {
text: "",
meta: {
@ -914,6 +934,13 @@ export class Client {
perm: "rw",
} as PageMeta,
};
this.system.system.invokeFunction("template.newPage", [pageName]).then(
() => {
this.focus();
},
).catch(
console.error,
);
} else {
this.flashNotification(
`Could not load page ${pageName}: ${e.message}`,

View File

@ -42,6 +42,7 @@ import { KVPrimitivesManifestCache } from "../plugos/manifest_cache.ts";
import { deepObjectMerge } from "$sb/lib/json.ts";
import { Query } from "$sb/types.ts";
import { PanelWidgetHook } from "./hooks/panel_widget.ts";
import { createKeyBindings } from "./editor_state.ts";
const plugNameExtractRegex = /\/(.+)\.plug\.js$/;
@ -103,6 +104,12 @@ export class ClientSystem {
type: "update-commands",
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);

View File

@ -131,9 +131,12 @@ export function attachmentExtension(editor: Client) {
if (currentNode) {
const fencedParentNode = findParentMatching(
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");
return false;
}

View File

@ -165,8 +165,13 @@ export class MarkdownWidget extends WidgetType {
el.addEventListener("click", (e) => {
e.preventDefault();
e.stopPropagation();
console.info("Command link clicked in widget, running", command);
this.client.runCommandByName(command).catch(console.error);
console.info(
"Command link clicked in widget, running",
parsedOnclick,
);
this.client.runCommandByName(command, parsedOnclick[2]).catch(
console.error,
);
});
}
});

View File

@ -30,13 +30,21 @@ export class LinkWidget extends WidgetType {
anchor.textContent = this.options.text;
// Mouse handling
anchor.addEventListener("mousedown", (e) => {
anchor.addEventListener("click", (e) => {
e.preventDefault();
e.stopPropagation();
});
anchor.addEventListener("mouseup", (e) => {
if (e.button !== 0) {
return;
}
e.preventDefault();
e.stopPropagation();
this.options.callback(e);
try {
this.options.callback(e);
} catch (e) {
console.error("Error handling wiki link click", e);
}
});
// Touch handling
@ -111,7 +119,7 @@ export class ButtonWidget extends WidgetType {
const anchor = document.createElement("button");
anchor.className = this.cssClass;
anchor.textContent = this.text;
anchor.addEventListener("click", (e) => {
anchor.addEventListener("mouseup", (e) => {
e.preventDefault();
e.stopPropagation();
this.callback(e);

View File

@ -40,7 +40,7 @@ export function CommandPalette({
});
if (commandOverride) {
shortcut = commandOverride;
console.log(`Shortcut override for ${name}:`, shortcut);
// console.log(`Shortcut override for ${name}:`, shortcut);
}
}
options.push({

View File

@ -47,7 +47,10 @@ export function FilterList({
}) {
const [text, setText] = useState("");
const [matchingOptions, setMatchingOptions] = useState(
fuzzySearchAndSort(options, ""),
fuzzySearchAndSort(
preFilter ? preFilter(options, "") : options,
"",
),
);
const [selectedOption, setSelectionOption] = useState(0);

View File

@ -84,7 +84,7 @@ export function MiniEditor(
}
};
}
}, [editorDiv]);
}, [editorDiv, placeholderText]);
useEffect(() => {
callbacksRef.current = {

View File

@ -11,12 +11,14 @@ export function PageNavigator({
onNavigate,
completer,
vimMode,
mode,
darkMode,
currentPage,
}: {
allPages: PageMeta[];
vimMode: boolean;
darkMode: boolean;
mode: "page" | "template";
onNavigate: (page: string | undefined) => void;
completer: (context: CompletionContext) => Promise<CompletionResult | null>;
currentPage?: string;
@ -72,7 +74,7 @@ export function PageNavigator({
}
return (
<FilterList
placeholder="Page"
placeholder={mode === "page" ? "Page" : "Template"}
label="Open"
options={options}
vimMode={vimMode}
@ -83,24 +85,35 @@ export function PageNavigator({
return phrase;
}}
preFilter={(options, phrase) => {
const allTags = phrase.match(tagRegex);
if (allTags) {
// Search phrase contains hash tags, let's pre-filter the results based on this
const filterTags = allTags.map((t) => t.slice(1));
if (mode === "page") {
const allTags = phrase.match(tagRegex);
if (allTags) {
// Search phrase contains hash tags, let's pre-filter the results based on this
const filterTags = allTags.map((t) => t.slice(1));
options = options.filter((pageMeta) => {
if (!pageMeta.tags) {
return false;
}
return filterTags.every((tag) =>
pageMeta.tags.find((itemTag: string) => itemTag.startsWith(tag))
);
});
}
options = options.filter((pageMeta) => {
if (!pageMeta.tags) {
return false;
}
return filterTags.every((tag) =>
pageMeta.tags.find((itemTag: string) => itemTag.startsWith(tag))
);
return !pageMeta.tags?.includes("template");
});
return options;
} else {
// Filter on pages tagged with "template"
options = options.filter((pageMeta) => {
return pageMeta.tags?.includes("template");
});
return options;
}
return options;
}}
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."
newHint="Create page"
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 ${mode}`}
completePrefix={completePrefix}
onSelect={(opt) => {
onNavigate(opt?.name);

View File

@ -66,7 +66,7 @@ export function TopBar({
// Then calculate a new width
currentPageElement.style.width = `${
Math.min(editorWidth - 170, innerDiv.clientWidth - 170)
Math.min(editorWidth - 200, innerDiv.clientWidth - 200)
}px`;
}
}

View File

@ -14,6 +14,7 @@ export {
Home as HomeIcon,
RefreshCw as RefreshCwIcon,
Terminal as TerminalIcon,
Type as TemplateIcon,
} from "https://esm.sh/preact-feather@4.2.1?external=preact";
// Vim mode

View File

@ -45,6 +45,7 @@ import { TextChange } from "$sb/lib/change.ts";
import { postScriptPrefacePlugin } from "./cm_plugins/top_bottom_panels.ts";
import { languageFor } from "../common/languages.ts";
import { plugLinter } from "./cm_plugins/lint.ts";
import { Compartment, Extension } from "@codemirror/state";
export function createEditorState(
client: Client,
@ -52,85 +53,16 @@ export function createEditorState(
text: string,
readOnly: boolean,
): 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;
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({
doc: text,
extensions: [
@ -209,48 +141,13 @@ export function createEditorState(
{ selector: "BulletList", class: "sb-line-ul" },
{ selector: "OrderedList", class: "sb-line-ol" },
{ 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",
mac: "Cmd-k",
run: (): boolean => {
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;
},
selector: "FrontMatter",
class: "sb-frontmatter",
disableSpellCheck: true,
},
]),
keyBindings,
EditorView.domEventHandlers({
// This may result in duplicated touch events on mobile devices
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;
},
},
]);
}

View File

@ -12,6 +12,7 @@ import {
preactRender,
RefreshCwIcon,
runScopeHandlers,
TemplateIcon,
TerminalIcon,
useEffect,
useReducer,
@ -20,6 +21,7 @@ import type { Client } from "./client.ts";
import { Panel } from "./components/panel.tsx";
import { h } from "./deps.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 {
viewState: AppViewState = initialViewState;
@ -44,7 +46,7 @@ export class MainUI {
if (ev.touches.length === 2) {
ev.stopPropagation();
ev.preventDefault();
client.startPageNavigate();
client.startPageNavigate("page");
}
// Launch the command palette using a three-finger tap
if (ev.touches.length === 3) {
@ -99,6 +101,7 @@ export class MainUI {
<PageNavigator
allPages={viewState.allPages}
currentPage={client.currentPage}
mode={viewState.pageNavigatorMode}
completer={client.miniEditorComplete.bind(client)}
vimMode={viewState.uiOptions.vimMode}
darkMode={viewState.uiOptions.darkMode}
@ -201,8 +204,8 @@ export class MainUI {
return;
}
console.log("Now renaming page to...", newName);
await client.system.system.loadedPlugs.get("index")!.invoke(
"renamePageCommand",
await client.system.system.invokeFunction(
"index.renamePageCommand",
[{ page: newName }],
);
client.focus();
@ -244,6 +247,8 @@ export class MainUI {
description: `Go to the index page (Alt-h)`,
callback: () => {
client.navigate("", 0);
// And let's make sure all panels are closed
dispatch({ type: "hide-filterbox" });
},
href: "",
},
@ -251,7 +256,16 @@ export class MainUI {
icon: BookIcon,
description: `Open page (${isMacLike() ? "Cmd-k" : "Ctrl-k"})`,
callback: () => {
client.startPageNavigate();
client.startPageNavigate("page");
},
},
{
icon: TemplateIcon,
description: `Open template (${
isMacLike() ? "Cmd-Shift-t" : "Ctrl-Shift-t"
})`,
callback: () => {
client.startPageNavigate("template");
},
},
{

View File

@ -1,6 +1,13 @@
import { Hook, Manifest } from "../../plugos/types.ts";
import { System } from "../../plugos/system.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 = {
name: string;
@ -31,10 +38,15 @@ export type CommandHookEvents = {
export class CommandHook extends EventEmitter<CommandHookEvents>
implements Hook<CommandHookT> {
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();
for (const plug of system.loadedPlugs.values()) {
for (const plug of this.system.loadedPlugs.values()) {
for (
const [name, functionDef] of Object.entries(
plug.manifest!.functions,
@ -52,18 +64,89 @@ export class CommandHook extends EventEmitter<CommandHookEvents>
});
}
}
await this.loadPageTemplateCommands();
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 {
this.system = system;
system.on({
plugLoaded: () => {
this.buildAllCommands(system);
this.throttledBuildAllCommands();
},
});
// On next tick
setTimeout(() => {
this.buildAllCommands(system);
this.throttledBuildAllCommands();
});
}

View File

@ -115,18 +115,10 @@ export class SlashCommandHook implements Hook<SlashCommandHookT> {
});
// Replace with whatever the completion is
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();
});
},

View File

@ -66,6 +66,7 @@ export default function reducer(
return {
...state,
showPageNavigator: true,
pageNavigatorMode: action.mode,
showCommandPalette: false,
showFilterBox: false,
};
@ -141,6 +142,8 @@ export default function reducer(
case "hide-filterbox":
return {
...state,
showCommandPalette: false,
showPageNavigator: false,
showFilterBox: false,
filterBoxOnSelect: () => {},
filterBoxPlaceHolder: "",

View File

@ -14,22 +14,22 @@ import { SysCallMapping } from "../../plugos/system.ts";
import type { FilterOption } from "../types.ts";
import { UploadFile } from "../../plug-api/types.ts";
export function editorSyscalls(editor: Client): SysCallMapping {
export function editorSyscalls(client: Client): SysCallMapping {
const syscalls: SysCallMapping = {
"editor.getCurrentPage": (): string => {
return editor.currentPage!;
return client.currentPage!;
},
"editor.getText": () => {
return editor.editorView.state.sliceDoc();
return client.editorView.state.sliceDoc();
},
"editor.getCursor": (): number => {
return editor.editorView.state.selection.main.from;
return client.editorView.state.selection.main.from;
},
"editor.getSelection": (): { from: number; to: number } => {
return editor.editorView.state.selection.main;
return client.editorView.state.selection.main;
},
"editor.save": () => {
return editor.save(true);
return client.save(true);
},
"editor.navigate": async (
_ctx,
@ -38,14 +38,18 @@ export function editorSyscalls(editor: Client): SysCallMapping {
replaceState = false,
newWindow = false,
) => {
await editor.navigate(name, pos, replaceState, newWindow);
await client.navigate(name, pos, replaceState, newWindow);
},
"editor.reloadPage": async () => {
await editor.reloadPage();
await client.reloadPage();
},
"editor.reloadUI": () => {
location.reload();
},
"editor.reloadSettingsAndCommands": async () => {
await client.loadSettings();
await client.system.commandHook.buildAllCommands();
},
"editor.openUrl": (_ctx, url: string, existingWindow = false) => {
if (!existingWindow) {
const win = window.open(url, "_blank");
@ -113,7 +117,7 @@ export function editorSyscalls(editor: Client): SysCallMapping {
message: string,
type: "error" | "info" = "info",
) => {
editor.flashNotification(message, type);
client.flashNotification(message, type);
},
"editor.filterBox": (
_ctx,
@ -122,7 +126,7 @@ export function editorSyscalls(editor: Client): SysCallMapping {
helpText = "",
placeHolder = "",
): Promise<FilterOption | undefined> => {
return editor.filterBox(label, options, helpText, placeHolder);
return client.filterBox(label, options, helpText, placeHolder);
},
"editor.showPanel": (
_ctx,
@ -131,28 +135,28 @@ export function editorSyscalls(editor: Client): SysCallMapping {
html: string,
script: string,
) => {
editor.ui.viewDispatch({
client.ui.viewDispatch({
type: "show-panel",
id: id as any,
config: { html, script, mode },
});
setTimeout(() => {
// Dummy dispatch to rerender the editor and toggle the panel
editor.editorView.dispatch({});
client.editorView.dispatch({});
});
},
"editor.hidePanel": (_ctx, id: string) => {
editor.ui.viewDispatch({
client.ui.viewDispatch({
type: "hide-panel",
id: id as any,
});
setTimeout(() => {
// Dummy dispatch to rerender the editor and toggle the panel
editor.editorView.dispatch({});
client.editorView.dispatch({});
});
},
"editor.insertAtPos": (_ctx, text: string, pos: number) => {
editor.editorView.dispatch({
client.editorView.dispatch({
changes: {
insert: text,
from: pos,
@ -160,7 +164,7 @@ export function editorSyscalls(editor: Client): SysCallMapping {
});
},
"editor.replaceRange": (_ctx, from: number, to: number, text: string) => {
editor.editorView.dispatch({
client.editorView.dispatch({
changes: {
insert: text,
from: from,
@ -169,13 +173,13 @@ export function editorSyscalls(editor: Client): SysCallMapping {
});
},
"editor.moveCursor": (_ctx, pos: number, center = false) => {
editor.editorView.dispatch({
client.editorView.dispatch({
selection: {
anchor: pos,
},
});
if (center) {
editor.editorView.dispatch({
client.editorView.dispatch({
effects: [
EditorView.scrollIntoView(
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.editorView.dispatch({
client.editorView.dispatch({
selection: {
anchor: from,
head: to,
@ -198,7 +202,7 @@ export function editorSyscalls(editor: Client): SysCallMapping {
},
"editor.insertAtCursor": (_ctx, text: string) => {
const editorView = editor.editorView;
const editorView = client.editorView;
const from = editorView.state.selection.main.from;
editorView.dispatch({
changes: {
@ -211,47 +215,55 @@ export function editorSyscalls(editor: Client): SysCallMapping {
});
},
"editor.dispatch": (_ctx, change: Transaction) => {
editor.editorView.dispatch(change);
client.editorView.dispatch(change);
},
"editor.prompt": (
_ctx,
message: string,
defaultValue = "",
): Promise<string | undefined> => {
return editor.prompt(message, defaultValue);
return client.prompt(message, defaultValue);
},
"editor.confirm": (_ctx, message: string): Promise<boolean> => {
return editor.confirm(message);
return client.confirm(message);
},
"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.ui.viewDispatch({
client.ui.viewDispatch({
type: "set-ui-option",
key,
value,
});
},
"editor.vimEx": (_ctx, exCommand: string) => {
const cm = vimGetCm(editor.editorView)!;
const cm = vimGetCm(client.editorView)!;
return Vim.handleEx(cm, exCommand);
},
"editor.openPageNavigator": (_ctx, mode: "page" | "template" = "page") => {
client.startPageNavigate(mode);
},
"editor.openCommandPalette": () => {
client.ui.viewDispatch({
type: "show-palette",
});
},
// Folding
"editor.fold": () => {
foldCode(editor.editorView);
foldCode(client.editorView);
},
"editor.unfold": () => {
unfoldCode(editor.editorView);
unfoldCode(client.editorView);
},
"editor.toggleFold": () => {
toggleFold(editor.editorView);
toggleFold(client.editorView);
},
"editor.foldAll": () => {
foldAll(editor.editorView);
foldAll(client.editorView);
},
"editor.unfoldAll": () => {
unfoldAll(editor.editorView);
unfoldAll(client.editorView);
},
};

View File

@ -18,11 +18,8 @@ export async function proxySyscall(
name: string,
args: any[],
): Promise<any> {
if (!ctx.plug) {
throw new Error(`Cannot proxy ${name} syscall without plug context`);
}
const resp = await httpSpacePrimitives.authenticatedFetch(
`${httpSpacePrimitives.url}/.rpc/${ctx.plug}/${name}`,
`${httpSpacePrimitives.url}/.rpc/${ctx.plug || "_"}/${name}`,
{
method: "POST",
body: JSON.stringify(args),

View File

@ -63,6 +63,9 @@ export type AppViewState = {
forcedROMode: boolean;
};
// Page navigator mode
pageNavigatorMode: "page" | "template";
// Filter box
showFilterBox: boolean;
filterBoxLabel: string;
@ -87,6 +90,7 @@ export const initialViewState: AppViewState = {
isLoading: false,
showPageNavigator: false,
showCommandPalette: false,
pageNavigatorMode: "page",
unsavedChanges: false,
syncFailures: 0,
uiOptions: {
@ -122,7 +126,7 @@ export type Action =
| { type: "page-saved" }
| { type: "sync-change"; syncSuccess: boolean }
| { type: "update-page-list"; allPages: PageMeta[] }
| { type: "start-navigate" }
| { type: "start-navigate"; mode: "page" | "template" }
| { type: "stop-navigate" }
| {
type: "update-commands";

16
website/Blocks.md Normal file
View 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.

View File

@ -2,9 +2,16 @@ An attempt at documenting the changes/new features introduced in each
release.
---
## Next
_Not yet released, this will likely become 0.6.0._
## Edge
_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 (thats a technical term) and have replaced a lot of previously built in (slash) commands. Theres 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, thats 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 hadnt 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]]):
* 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.
* 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 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.
* **“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:
* Filter list (used by [[Page Picker]], [[Template Picker]] and [[Command Palette]]) improvements:
* Better ranking
* Better positioning of modal (especially on mobile)
* Better mouse behavior
* Templates:
* 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.
* 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
* Move from [Oak](https://oakserver.github.io/oak/) to [Hono](https://hono.dev/)
* 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
* 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 wont work, so now its 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 yall prepared to move away from directives.
* Its 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
* Were 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).
* 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]]
* 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]]:
@ -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:
* [[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 dont 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. Theres still more value to be unlocked here in future releases.
* [[Live Queries]] and [[Live Templates]]: ultimately will replace directives in future versions and **directives are now deprecated.** They differ from directives in that they dont 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 directives) has been significantly expanded, although there may still be bugs. Theres 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]}.
* A whole bunch of [[PlugOS]] syscalls have been updated. Ill 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]] (its 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` (its 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**:
* 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 its 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 its 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.
@ -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]].
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.
* [[Plugs/Tasks]] now support custom states (not just `[x]` and `[ ]`), for example:
* [IN PROGRESS] An in progress task

View File

@ -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]]).

View File

@ -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.
* **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.
**Note:** Federation does not support authentication yet, so all federated spaces need to be unauthenticated and will be _read-only_.

View File

@ -19,6 +19,19 @@ Here is another example:
## This is a section
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
While SilverBullet allows arbitrary metadata to be added to pages, there are a few attributes with special meaning:

View File

@ -1,17 +1,23 @@
Welcome to SilverBullet. Since youre 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? Lets proceed.
## 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:
* 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.
* You _can_ create pages in folders (if youre into that type of thing) simply by putting slashes (`/`) in the name (even on Windows), e.g. `My Folder/My Page`. Dont worry about that folder existing, well automatically create it if it doesnt.
* 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_.
* 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).
* [ ] Tap this box 👈 to mark this task as done.
* Start typing `:party` to trigger the emoji picker 🎉
* 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!_
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 whats 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/)
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 youre running the Deno version). If you run into any issues or have ideas on how to make SilverBullet even awesomer (yes, thats 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]].

View File

@ -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. Dont 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.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 143 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 115 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 186 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 252 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 208 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 123 KiB

View File

@ -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).
* [[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
* [[Guide/Deployment/Cloudflare and Portainer]]

View File

@ -9,8 +9,6 @@ Particularly useful keyboard shortcuts (that you may not know about).
| Cmd-z/Ctrl-z | Undo the latest change |
| Cmd-u/Ctrl-u | Go one change ahead |
| 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 |
# System
| 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) |
| Alt-q | Refresh all live queries and templates on this page |
| 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 |
# Navigation

16
website/Libraries.md Normal file
View 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.
# Whats 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 were 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.

Some files were not shown because too many files have changed in this diff Show More