From 70ef6ed9da704ce84e9aa6eb957d4dacebedb4a6 Mon Sep 17 00:00:00 2001 From: Zef Hemel Date: Thu, 21 Dec 2023 18:37:50 +0100 Subject: [PATCH] Work on #587: revamped templates --- plugs/directive/complete.ts | 35 ---- plugs/directive/directive.plug.yaml | 5 - plugs/editor/complete.ts | 45 ++++- plugs/editor/page.ts | 11 -- plugs/federation/federation.ts | 11 +- plugs/index/api.ts | 23 ++- plugs/index/attributes.ts | 59 +++++-- plugs/index/builtins.ts | 98 +++++----- plugs/index/cheap_yaml.test.ts | 10 ++ plugs/index/cheap_yaml.ts | 34 ++++ plugs/index/lint.ts | 34 +++- plugs/index/page.ts | 8 +- plugs/index/toc.ts | 2 +- plugs/template/api.ts | 5 +- plugs/template/complete.ts | 111 ++++++++++++ plugs/template/template.plug.yaml | 39 ++-- plugs/template/template.ts | 226 ++++++------------------ plugs/template/types.ts | 3 +- web/client.ts | 31 ++-- web/client_system.ts | 11 +- web/components/filter.tsx | 8 +- web/components/fuse_search.ts | 17 +- web/components/page_navigator.tsx | 18 ++ web/editor_state.ts | 4 +- web/editor_ui.tsx | 55 +++--- web/reducer.ts | 25 ++- web/styles/colors.scss | 12 +- web/styles/modals.scss | 6 +- web/styles/theme.scss | 2 + web/types.ts | 4 +- website/Frontmatter.md | 11 +- website/Live Templates.md | 9 +- website/Page Templates.md | 26 +++ website/Plugs/Template.md | 56 ------ website/SETTINGS.md | 4 - website/SilverBullet.md | 1 + website/Slash Commands.md | 1 - website/Slash Templates.md | 13 +- website/Templates.md | 56 ++++-- website/template/page/slash-template.md | 9 + website/template/page/template.md | 10 ++ website/template/today.md | 2 +- 42 files changed, 664 insertions(+), 486 deletions(-) create mode 100644 plugs/index/cheap_yaml.test.ts create mode 100644 plugs/index/cheap_yaml.ts create mode 100644 plugs/template/complete.ts create mode 100644 website/Page Templates.md create mode 100644 website/template/page/slash-template.md create mode 100644 website/template/page/template.md diff --git a/plugs/directive/complete.ts b/plugs/directive/complete.ts index 8b5c980..b382077 100644 --- a/plugs/directive/complete.ts +++ b/plugs/directive/complete.ts @@ -64,41 +64,6 @@ export async function queryComplete(completeEvent: CompleteEvent) { return null; } -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 function attributeCompletionsToCMCompletion( completions: AttributeCompletion[], ) { diff --git a/plugs/directive/directive.plug.yaml b/plugs/directive/directive.plug.yaml index f9d14f6..ecccd87 100644 --- a/plugs/directive/directive.plug.yaml +++ b/plugs/directive/directive.plug.yaml @@ -23,11 +23,6 @@ functions: path: ./complete.ts:queryComplete events: - editor:complete - handlebarHelperComplete: - path: ./complete.ts:templateVariableComplete - events: - - editor:complete - # Conversion convertToLiveQuery: path: command.ts:convertToLive diff --git a/plugs/editor/complete.ts b/plugs/editor/complete.ts index c1c2d83..5b2ceaf 100644 --- a/plugs/editor/complete.ts +++ b/plugs/editor/complete.ts @@ -1,7 +1,7 @@ import { CompleteEvent } from "$sb/app_event.ts"; -import { space } from "$sb/syscalls.ts"; import { FileMeta, PageMeta } from "$sb/types.ts"; import { cacheFileListing } from "../federation/federation.ts"; +import { queryObjects } from "../index/plug_api.ts"; // Completion export async function pageComplete(completeEvent: CompleteEvent) { @@ -9,7 +9,16 @@ export async function pageComplete(completeEvent: CompleteEvent) { if (!match) { return null; } - let allPages: PageMeta[] = await space.listPages(); + // When we're in fenced code block, we likely want to complete a page name without an alias, and only complete template pages + // so let's check if we're in a template context + const isInTemplateContext = + completeEvent.parentNodes.find((node) => node.startsWith("FencedCode")) && + // either a render [[bla]] clause or page: "[[bla]]" template block + /render\s+\[\[|page:\s*["']\[\[/.test( + completeEvent.linePrefix, + ); + const tagToQuery = isInTemplateContext ? "template" : "page"; + let allPages: PageMeta[] = await queryObjects(tagToQuery, {}); const prefix = match[1]; if (prefix.startsWith("!")) { // Federation prefix, let's first see if we're matching anything from federation that is locally synced @@ -34,12 +43,38 @@ export async function pageComplete(completeEvent: CompleteEvent) { return { from: completeEvent.pos - match[1].length, options: allPages.map((pageMeta) => { - return { + const completions: any[] = []; + if (pageMeta.displayName) { + completions.push({ + label: pageMeta.displayName, + boost: pageMeta.lastModified, + apply: isInTemplateContext + ? pageMeta.name + : `${pageMeta.name}|${pageMeta.displayName}`, + detail: "alias", + type: "page", + }); + } + if (Array.isArray(pageMeta.aliases)) { + for (const alias of pageMeta.aliases) { + completions.push({ + label: alias, + boost: pageMeta.lastModified, + apply: isInTemplateContext + ? pageMeta.name + : `${pageMeta.name}|${alias}`, + detail: "alias", + type: "page", + }); + } + } + completions.push({ label: pageMeta.name, boost: pageMeta.lastModified, type: "page", - }; - }), + }); + return completions; + }).flat(), }; } diff --git a/plugs/editor/page.ts b/plugs/editor/page.ts index 4833fef..3b086f0 100644 --- a/plugs/editor/page.ts +++ b/plugs/editor/page.ts @@ -45,14 +45,3 @@ export async function copyPage() { console.log("Navigating to new page"); await editor.navigate(newName); } - -export async function newPageCommand() { - const allPages = await space.listPages(); - let pageName = `Untitled`; - let i = 1; - while (allPages.find((p) => p.name === pageName)) { - pageName = `Untitled ${i}`; - i++; - } - await editor.navigate(pageName); -} diff --git a/plugs/federation/federation.ts b/plugs/federation/federation.ts index e36cb1a..bc67b28 100644 --- a/plugs/federation/federation.ts +++ b/plugs/federation/federation.ts @@ -82,7 +82,7 @@ export async function cacheFileListing(uri: string): Promise { const r = await nativeFetch(indexUrl, { method: "GET", headers: { - Accept: "application/json", + "X-Sync-Mode": "true", "Cache-Control": "no-cache", }, signal: fetchController.signal, @@ -119,7 +119,13 @@ export async function readFile( name: string, ): Promise<{ data: Uint8Array; meta: FileMeta } | undefined> { const url = federatedPathToUrl(name); - const r = await nativeFetch(url); + console.log("Fetfching fedderated file", url); + const r = await nativeFetch(url, { + method: "GET", + headers: { + "X-Sync-Mode": "true", + }, + }); if (r.status === 503) { throw new Error("Offline"); } @@ -195,6 +201,7 @@ export async function getFileMeta(name: string): Promise { const r = await nativeFetch(url, { method: "GET", headers: { + "X-Sync-Mode": "true", "X-Get-Meta": "true", }, }); diff --git a/plugs/index/api.ts b/plugs/index/api.ts index 6d2fdc4..ff111f9 100644 --- a/plugs/index/api.ts +++ b/plugs/index/api.ts @@ -72,6 +72,7 @@ export async function indexObjects( const allAttributes = new Map(); // tag:name -> attributeType for (const obj of objects) { for (const tag of obj.tags) { + // The object itself kvs.push({ key: [tag, cleanKey(obj.ref, page)], value: obj, @@ -79,8 +80,8 @@ export async function indexObjects( // Index attributes const builtinAttributes = builtins[tag]; if (!builtinAttributes) { - // For non-builtin tags, index all attributes - for ( + // This is not a builtin tag, so we index all attributes (almost, see below) + attributeLabel: for ( const [attrName, attrValue] of Object.entries( obj as Record, ) @@ -88,6 +89,18 @@ export async function indexObjects( if (attrName.startsWith("$")) { continue; } + // Check for all tags attached to this object if they're builtins + // If so: if `attrName` is defined in the builtin, use the attributeType from there (mostly to preserve readOnly aspects) + for (const otherTag of obj.tags) { + const builtinAttributes = builtins[otherTag]; + if (builtinAttributes && builtinAttributes[attrName]) { + allAttributes.set( + `${tag}:${attrName}`, + builtinAttributes[attrName], + ); + continue attributeLabel; + } + } allAttributes.set(`${tag}:${attrName}`, determineType(attrValue)); } } else if (tag !== "attribute") { @@ -112,12 +125,16 @@ export async function indexObjects( page, [...allAttributes].map(([key, value]) => { const [tag, name] = key.split(":"); + const attributeType = value.startsWith("!") + ? value.substring(1) + : value; return { ref: key, tags: ["attribute"], tag, name, - attributeType: value, + attributeType, + readOnly: value.startsWith("!"), page, }; }), diff --git a/plugs/index/attributes.ts b/plugs/index/attributes.ts index c298341..ca14992 100644 --- a/plugs/index/attributes.ts +++ b/plugs/index/attributes.ts @@ -1,14 +1,15 @@ import type { CompleteEvent } from "$sb/app_event.ts"; import { events } from "$sb/syscalls.ts"; -import { getObjectByRef, queryObjects } from "./api.ts"; +import { queryObjects } from "./api.ts"; import { ObjectValue, QueryExpression } from "$sb/types.ts"; -import { builtinPseudoPage } from "./builtins.ts"; +import { determineTags } from "./cheap_yaml.ts"; export type AttributeObject = ObjectValue<{ name: string; attributeType: string; tag: string; page: string; + readOnly: boolean; }>; export type AttributeCompleteEvent = { @@ -20,7 +21,7 @@ export type AttributeCompletion = { name: string; source: string; attributeType: string; - builtin?: boolean; + readOnly: boolean; }; export function determineType(v: any): string { @@ -33,31 +34,53 @@ export function determineType(v: any): string { return t; } +/** + * Triggered by the `attribute:complete:*` event (that is: gimme all attribute completions) + * @param attributeCompleteEvent + * @returns + */ export async function objectAttributeCompleter( attributeCompleteEvent: AttributeCompleteEvent, ): Promise { + const prefixFilter: QueryExpression = ["call", "startsWith", [[ + "attr", + "name", + ], ["string", attributeCompleteEvent.prefix]]]; const attributeFilter: QueryExpression | undefined = attributeCompleteEvent.source === "" - ? undefined - : ["=", ["attr", "tag"], ["string", attributeCompleteEvent.source]]; + ? prefixFilter + : ["and", prefixFilter, ["=", ["attr", "tag"], [ + "string", + attributeCompleteEvent.source, + ]]]; const allAttributes = await queryObjects("attribute", { filter: attributeFilter, + distinct: true, + select: [{ name: "name" }, { name: "attributeType" }, { name: "tag" }, { + name: "readOnly", + }], }); return allAttributes.map((value) => { return { name: value.name, source: value.tag, attributeType: value.attributeType, - builtin: value.page === builtinPseudoPage, + readOnly: value.readOnly, } as AttributeCompletion; }); } +/** + * Offer completions for _setting_ attributes on objects (either in frontmatter or inline) + * Triggered by `editor:complete` events from the editor + */ export async function attributeComplete(completeEvent: CompleteEvent) { if (/([\-\*]\s+\[)([^\]]+)$/.test(completeEvent.linePrefix)) { // Don't match task states, which look similar return null; } + + // Inline attribute completion (e.g. [myAttr: 10]) const inlineAttributeMatch = /([^\[\{}]|^)\[(\w+)$/.exec( completeEvent.linePrefix, ); @@ -79,22 +102,24 @@ export async function attributeComplete(completeEvent: CompleteEvent) { return { from: completeEvent.pos - inlineAttributeMatch[2].length, options: attributeCompletionsToCMCompletion( - completions.filter((completion) => !completion.builtin), + // Filter out read-only attributes + completions.filter((completion) => !completion.readOnly), ), }; } + + // Frontmatter attribute completion const attributeMatch = /^(\w+)$/.exec(completeEvent.linePrefix); if (attributeMatch) { - if (completeEvent.parentNodes.includes("FrontMatter")) { - const pageMeta = await getObjectByRef( - completeEvent.pageName, + const frontmatterParent = completeEvent.parentNodes.find((node) => + node.startsWith("FrontMatter:") + ); + if (frontmatterParent) { + const tags = [ "page", - completeEvent.pageName, - ); - let tags = ["page"]; - if (pageMeta?.tags) { - tags = pageMeta.tags; - } + ...determineTags(frontmatterParent.slice("FrontMatter:".length)), + ]; + const completions = (await Promise.all(tags.map((tag) => events.dispatchEvent( `attribute:complete:${tag}`, @@ -109,7 +134,7 @@ export async function attributeComplete(completeEvent: CompleteEvent) { from: completeEvent.pos - attributeMatch[1].length, options: attributeCompletionsToCMCompletion( completions.filter((completion) => - !completion.builtin + !completion.readOnly ), ), }; diff --git a/plugs/index/builtins.ts b/plugs/index/builtins.ts index 71907a4..c444554 100644 --- a/plugs/index/builtins.ts +++ b/plugs/index/builtins.ts @@ -5,70 +5,76 @@ import { TagObject } from "./tags.ts"; export const builtinPseudoPage = ":builtin:"; +// Types marked with a ! are read-only, they cannot be set by the user export const builtins: Record> = { page: { - ref: "string", - name: "string", - lastModified: "date", - perm: "rw|ro", - contentType: "string", - size: "number", + ref: "!string", + name: "!string", + displayName: "string", + aliases: "array", + created: "!date", + lastModified: "!date", + perm: "!rw|ro", + contentType: "!string", + size: "!number", tags: "array", }, task: { - ref: "string", - name: "string", - done: "boolean", - page: "string", - state: "string", + ref: "!string", + name: "!string", + done: "!boolean", + page: "!string", + state: "!string", deadline: "string", - pos: "number", + pos: "!number", tags: "array", }, taskstate: { - ref: "string", - tags: "array", - state: "string", - count: "number", - page: "string", + ref: "!string", + tags: "!array", + state: "!string", + count: "!number", + page: "!string", }, tag: { - ref: "string", - name: "string", - page: "string", - context: "string", + ref: "!string", + name: "!string", + page: "!string", + context: "!string", }, attribute: { - ref: "string", - name: "string", - attributeType: "string", - type: "string", - page: "string", + ref: "!string", + name: "!string", + attributeType: "!string", + type: "!string", + page: "!string", }, anchor: { - ref: "string", - name: "string", - page: "string", - pos: "number", + ref: "!string", + name: "!string", + page: "!string", + pos: "!number", }, link: { - ref: "string", - name: "string", - page: "string", - pos: "number", - alias: "string", - inDirective: "boolean", - asTemplate: "boolean", + ref: "!string", + name: "!string", + page: "!string", + pos: "!number", + alias: "!string", + inDirective: "!boolean", + asTemplate: "!boolean", }, paragraph: { - text: "string", - page: "string", - pos: "number", + text: "!string", + page: "!string", + pos: "!number", }, template: { - ref: "string", - page: "string", - pos: "number", + ref: "!string", + page: "!string", + pageName: "string", + pos: "!number", + type: "string", trigger: "string", }, }; @@ -92,8 +98,10 @@ export async function loadBuiltinsIntoIndex() { tags: ["attribute"], tag, name, - attributeType, - builtinPseudoPage, + attributeType: attributeType.startsWith("!") + ? attributeType.substring(1) + : attributeType, + readOnly: attributeType.startsWith("!"), page: builtinPseudoPage, }; }), diff --git a/plugs/index/cheap_yaml.test.ts b/plugs/index/cheap_yaml.test.ts new file mode 100644 index 0000000..ed00748 --- /dev/null +++ b/plugs/index/cheap_yaml.test.ts @@ -0,0 +1,10 @@ +import { assertEquals } from "../../test_deps.ts"; +import { determineTags } from "./cheap_yaml.ts"; + +Deno.test("cheap yaml", () => { + assertEquals([], determineTags("")); + assertEquals([], determineTags("hank: bla")); + assertEquals(["template"], determineTags("tags: template")); + assertEquals(["bla", "template"], determineTags("tags: bla,template")); + assertEquals(["bla", "template"], determineTags("tags:\n- bla\n- template")); +}); diff --git a/plugs/index/cheap_yaml.ts b/plugs/index/cheap_yaml.ts new file mode 100644 index 0000000..fc792fa --- /dev/null +++ b/plugs/index/cheap_yaml.ts @@ -0,0 +1,34 @@ +const yamlKvRegex = /^\s*(\w+):\s*(.*)/; +const yamlListItemRegex = /^\s*-\s+(.+)/; + +/** + * Cheap YAML parser to determine tags (ugly, regex based but fast) + * @param yamlText + * @returns + */ +export function determineTags(yamlText: string): string[] { + const lines = yamlText.split("\n"); + let inTagsSection = false; + const tags: string[] = []; + for (const line of lines) { + const yamlKv = yamlKvRegex.exec(line); + if (yamlKv) { + const [key, value] = yamlKv.slice(1); + // Looking for a 'tags' key + if (key === "tags") { + inTagsSection = true; + // 'template' there? Yay! + if (value) { + tags.push(...value.split(/,\s*/)); + } + } else { + inTagsSection = false; + } + } + const yamlListem = yamlListItemRegex.exec(line); + if (yamlListem && inTagsSection) { + tags.push(yamlListem[1]); + } + } + return tags; +} diff --git a/plugs/index/lint.ts b/plugs/index/lint.ts index 9467635..4240bbf 100644 --- a/plugs/index/lint.ts +++ b/plugs/index/lint.ts @@ -1,19 +1,38 @@ import { YAML } from "$sb/syscalls.ts"; -import { LintDiagnostic } from "$sb/types.ts"; +import { LintDiagnostic, QueryExpression } from "$sb/types.ts"; import { findNodeOfType, renderToText, traverseTreeAsync, } from "$sb/lib/tree.ts"; import { LintEvent } from "$sb/app_event.ts"; +import { queryObjects } from "./api.ts"; +import { AttributeObject } from "./attributes.ts"; +import { extractFrontmatter } from "$sb/lib/frontmatter.ts"; export async function lintYAML({ tree }: LintEvent): Promise { const diagnostics: LintDiagnostic[] = []; + const frontmatter = await extractFrontmatter(tree); + // Query all readOnly attributes for pages with this tag set + const readOnlyAttributes = await queryObjects("attribute", { + filter: ["and", ["=", ["attr", "tag"], [ + "array", + frontmatter.tags.map((tag): QueryExpression => ["string", tag]), + ]], [ + "=", + ["attr", "readOnly"], + ["boolean", true], + ]], + distinct: true, + select: [{ name: "name" }], + }); + // console.log("All read only attributes", readOnlyAttributes); await traverseTreeAsync(tree, async (node) => { if (node.type === "FrontMatterCode") { const lintResult = await lintYaml( renderToText(node), node.from!, + readOnlyAttributes.map((a) => a.name), ); if (lintResult) { diagnostics.push(lintResult); @@ -56,9 +75,20 @@ const errorRegex = /\((\d+):(\d+)\)/; async function lintYaml( yamlText: string, from: number, + disallowedKeys: string[] = [], ): Promise { try { - await YAML.parse(yamlText); + const parsed = await YAML.parse(yamlText); + for (const key of disallowedKeys) { + if (parsed[key]) { + return { + from, + to: from + yamlText.length, + severity: "error", + message: `Disallowed key "${key}"`, + }; + } + } } catch (e) { const errorMatch = errorRegex.exec(e.message); if (errorMatch) { diff --git a/plugs/index/page.ts b/plugs/index/page.ts index de2d462..77c470f 100644 --- a/plugs/index/page.ts +++ b/plugs/index/page.ts @@ -26,10 +26,10 @@ export async function indexPage({ name, tree }: IndexTreeEvent) { pageMeta.tags = [...new Set(["page", ...pageMeta.tags || []])]; - if (pageMeta.tags.includes("template")) { - // If this is a template, we don't want to index it as a page or anything else, just a template - pageMeta.tags = ["template"]; - } + // if (pageMeta.tags.includes("template")) { + // // If this is a template, we don't want to index it as a page or anything else, just a template + // pageMeta.tags = ["template"]; + // } // console.log("Page object", pageObj); await indexObjects(name, [pageMeta]); diff --git a/plugs/index/toc.ts b/plugs/index/toc.ts index 673e49f..e6cafa5 100644 --- a/plugs/index/toc.ts +++ b/plugs/index/toc.ts @@ -59,7 +59,7 @@ export async function renderTOC(reload = false) { } cachedTOC = JSON.stringify(headers); if (headers.length < headerThreshold) { - console.log("Not enough headers, not showing TOC", headers.length); + // console.log("Not enough headers, not showing TOC", headers.length); await editor.hidePanel("top"); return; } diff --git a/plugs/template/api.ts b/plugs/template/api.ts index 179f9ae..169268e 100644 --- a/plugs/template/api.ts +++ b/plugs/template/api.ts @@ -16,7 +16,7 @@ export async function renderTemplate( templateText: string, pageMeta: PageMeta, data: any = {}, -): Promise<{ frontmatter?: string; text: string }> { +): Promise<{ renderedFrontmatter?: string; frontmatter: any; text: string }> { const tree = await markdown.parseMarkdown(templateText); const frontmatter: Partial = await extractFrontmatter(tree, { removeFrontmatterSection: true, @@ -36,7 +36,8 @@ export async function renderTemplate( }); } return { - frontmatter: frontmatterText, + frontmatter, + renderedFrontmatter: frontmatterText, text: await handlebars.renderTemplate(templateText, data, { page: pageMeta, }), diff --git a/plugs/template/complete.ts b/plugs/template/complete.ts new file mode 100644 index 0000000..8f5d567 --- /dev/null +++ b/plugs/template/complete.ts @@ -0,0 +1,111 @@ +import { CompleteEvent, SlashCompletion } from "$sb/app_event.ts"; +import { PageMeta } from "$sb/types.ts"; +import { editor, events, markdown, space } from "$sb/syscalls.ts"; +import { buildHandebarOptions } from "../directive/util.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"; + +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 { + const allTemplates = await queryObjects("template", { + // Only return templates that have a trigger + filter: ["!=", ["attr", "trigger"], ["null"]], + }); + 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", + }), + ); +} diff --git a/plugs/template/template.plug.yaml b/plugs/template/template.plug.yaml index f0b2814..aa71553 100644 --- a/plugs/template/template.plug.yaml +++ b/plugs/template/template.plug.yaml @@ -6,24 +6,30 @@ functions: cleanTemplate: path: api.ts:cleanTemplate + # Used by various slash commands insertTemplateText: path: template.ts:insertTemplateText - indexTemplate: path: ./index.ts:indexTemplate events: + # Special event only triggered for template pages - page:indexTemplate + # Completion templateSlashCommand: - path: ./template.ts:templateSlashComplete + path: ./complete.ts:templateSlashComplete events: - slash:complete insertSlashTemplate: - path: ./template.ts:insertSlashTemplate + path: ./complete.ts:insertSlashTemplate + + handlebarHelperComplete: + path: ./complete.ts:templateVariableComplete + events: + - editor:complete - # Template commands applyLineReplace: path: ./template.ts:applyLineReplace insertFrontMatter: @@ -79,6 +85,7 @@ functions: name: hr description: Insert a horizontal rule value: "---" + insertTable: redirect: insertTemplateText slashCommand: @@ -89,44 +96,38 @@ functions: | Header A | Header B | |----------|----------| | Cell A|^| | Cell B | + quickNoteCommand: path: ./template.ts:quickNoteCommand command: name: "Quick Note" key: "Alt-Shift-n" + 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" - instantiateTemplateCommand: - path: ./template.ts:instantiateTemplateCommand + newPageCommand: + path: ./template.ts:newPageCommand command: - name: "Template: Instantiate Page" - insertSnippet: - path: ./template.ts:insertSnippet - command: - name: "Template: Insert Snippet" - slashCommand: - name: snippet - description: Insert a snippet - applyPageTemplateCommand: - path: ./template.ts:applyPageTemplateCommand - slashCommand: - name: page-template - description: Apply a page template + 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: diff --git a/plugs/template/template.ts b/plugs/template/template.ts index dd6a0fd..bd52fea 100644 --- a/plugs/template/template.ts +++ b/plugs/template/template.ts @@ -1,96 +1,40 @@ import { editor, handlebars, markdown, space, YAML } from "$sb/syscalls.ts"; -import { - extractFrontmatter, - prepareFrontmatterDispatch, -} from "$sb/lib/frontmatter.ts"; -import { renderToText } from "$sb/lib/tree.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 { CompleteEvent, SlashCompletion } from "$sb/app_event.ts"; import { getObjectByRef, queryObjects } from "../index/plug_api.ts"; import { TemplateObject } from "./types.ts"; import { renderTemplate } from "./api.ts"; -export async function templateSlashComplete( - completeEvent: CompleteEvent, -): Promise { - const allTemplates = await queryObjects("template", { - // Only return templates that have a trigger - filter: ["!=", ["attr", "trigger"], ["null"]], - }); - return allTemplates.map((template) => ({ - label: template.trigger!, - detail: "template", - templatePage: template.ref, - pageName: completeEvent.pageName, - invoke: "template.insertSlashTemplate", - })); -} +export async function newPageCommand( + _cmdDef: any, + templateName?: string, + askName = true, +) { + if (!templateName) { + const allPageTemplates = await queryObjects("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 #template and 'page' set as 'type')`, + ); -export async function insertSlashTemplate(slashCompletion: SlashCompletion) { - const pageObject = await loadPageObject(slashCompletion.pageName); - - const templateText = await space.readPage(slashCompletion.templatePage); - let { frontmatter, text } = await renderTemplate(templateText, pageObject); - - let cursorPos = await editor.getCursor(); - - if (frontmatter) { - frontmatter = frontmatter.trim(); - const pageText = await editor.getText(); - const tree = await markdown.parseMarkdown(pageText); - - const dispatch = await prepareFrontmatterDispatch(tree, frontmatter); - if (cursorPos === 0) { - dispatch.selection = { anchor: frontmatter.length + 9 }; + if (!selectedTemplate) { + return; } - await editor.dispatch(dispatch); + templateName = selectedTemplate.ref; } + console.log("Selected template", templateName); - cursorPos = await editor.getCursor(); - const carretPos = text.indexOf("|^|"); - text = text.replace("|^|", ""); - await editor.insertAtCursor(text); - if (carretPos !== -1) { - await editor.moveCursor(cursorPos + carretPos); - } -} - -export async function instantiateTemplateCommand() { - const allPages = await space.listPages(); - const { pageTemplatePrefix } = await readSettings({ - pageTemplatePrefix: "template/page/", - }); - - const selectedTemplate = await editor.filterBox( - "Template", - allPages - .filter((pageMeta) => pageMeta.name.startsWith(pageTemplatePrefix)) - .map((pageMeta) => ({ - ...pageMeta, - name: pageMeta.name.slice(pageTemplatePrefix.length), - })), - `Select the template to create a new page from (listing any page starting with ${pageTemplatePrefix})`, - ); - - if (!selectedTemplate) { - return; - } - console.log("Selected template", selectedTemplate); - - const text = await space.readPage( - `${pageTemplatePrefix}${selectedTemplate.name}`, - ); - - const parseTree = await markdown.parseMarkdown(text); - const additionalPageMeta = await extractFrontmatter(parseTree, { - removeKeys: [ - "$name", - "$disableDirectives", - ], - }); + const templateText = await space.readPage(templateName!); const tempPageMeta: PageMeta = { tags: ["page"], @@ -100,20 +44,25 @@ export async function instantiateTemplateCommand() { lastModified: "", perm: "rw", }; - - if (additionalPageMeta.$name) { - additionalPageMeta.$name = await replaceTemplateVars( - additionalPageMeta.$name, - tempPageMeta, - ); - } - - const pageName = await editor.prompt( - "Name of new page", - additionalPageMeta.$name, + // Just used to extract the frontmatter + const { frontmatter } = await renderTemplate( + templateText, + tempPageMeta, ); - if (!pageName) { - return; + + 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; @@ -127,92 +76,27 @@ export async function instantiateTemplateCommand() { `Page ${pageName} already exists, are you sure you want to override it?`, ) ) { - return; + // Just navigate there without instantiating + return editor.navigate(pageName); } } catch { // The preferred scenario, let's keep going } - const pageText = await replaceTemplateVars( - renderToText(parseTree), + const { text: pageText, renderedFrontmatter } = await renderTemplate( + templateText, tempPageMeta, ); - await space.writePage(pageName, pageText); - await editor.navigate(pageName); -} - -export async function insertSnippet() { - const allPages = await space.listPages(); - const { snippetPrefix } = await readSettings({ - snippetPrefix: "snippet/", - }); - const cursorPos = await editor.getCursor(); - const page = await editor.getCurrentPage(); - const pageMeta = await space.getPageMeta(page); - const allSnippets = allPages - .filter((pageMeta) => pageMeta.name.startsWith(snippetPrefix)) - .map((pageMeta) => ({ - ...pageMeta, - name: pageMeta.name.slice(snippetPrefix.length), - })); - - const selectedSnippet = await editor.filterBox( - "Snippet", - allSnippets, - `Select the snippet to insert (listing any page starting with ${snippetPrefix})`, + let fullPageText = renderedFrontmatter + ? "---\n" + renderedFrontmatter + "---\n" + pageText + : pageText; + const carretPos = fullPageText.indexOf("|^|"); + fullPageText = fullPageText.replace("|^|", ""); + await space.writePage( + pageName, + fullPageText, ); - - if (!selectedSnippet) { - return; - } - - const text = await space.readPage(`${snippetPrefix}${selectedSnippet.name}`); - let templateText = await replaceTemplateVars(text, pageMeta); - 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 applyPageTemplateCommand() { - const allPages = await space.listPages(); - const { pageTemplatePrefix } = await readSettings({ - pageTemplatePrefix: "template/page/", - }); - const cursorPos = await editor.getCursor(); - const page = await editor.getCurrentPage(); - const pageMeta = await space.getPageMeta(page); - const allSnippets = allPages - .filter((pageMeta) => pageMeta.name.startsWith(pageTemplatePrefix)) - .map((pageMeta) => ({ - ...pageMeta, - name: pageMeta.name.slice(pageTemplatePrefix.length), - })); - - const selectedPage = await editor.filterBox( - "Page template", - allSnippets, - `Select the page template to apply (listing any page starting with ${pageTemplatePrefix})`, - ); - - if (!selectedPage) { - return; - } - - const text = await space.readPage( - `${pageTemplatePrefix}${selectedPage.name}`, - ); - let templateText = await replaceTemplateVars(text, pageMeta); - const carretPos = templateText.indexOf("|^|"); - templateText = templateText.replace("|^|", ""); - templateText = await replaceTemplateVars(templateText, pageMeta); - await editor.insertAtCursor(templateText); - if (carretPos !== -1) { - await editor.moveCursor(cursorPos + carretPos); - } + await editor.navigate(pageName, carretPos !== -1 ? carretPos : undefined); } export async function loadPageObject(pageName?: string): Promise { diff --git a/plugs/template/types.ts b/plugs/template/types.ts index 260a59a..6ed1987 100644 --- a/plugs/template/types.ts +++ b/plugs/template/types.ts @@ -2,7 +2,8 @@ import { ObjectValue } from "$sb/types.ts"; export type TemplateFrontmatter = { trigger?: string; // slash command name - scope?: string; + displayName?: string; + type?: "page"; // Frontmatter can be encoded as an object (in which case we'll serialize it) or as a string frontmatter?: Record | string; }; diff --git a/web/client.ts b/web/client.ts index 2ca7c49..fef1e74 100644 --- a/web/client.ts +++ b/web/client.ts @@ -7,7 +7,7 @@ import { SyntaxNode, syntaxTree, } from "../common/deps.ts"; -import { fileMetaToPageMeta, Space } from "./space.ts"; +import { Space } from "./space.ts"; import { FilterOption } from "./types.ts"; import { ensureSettingsAndIndex } from "../common/util.ts"; import { EventHook } from "../plugos/hooks/event.ts"; @@ -44,7 +44,6 @@ import { IndexedDBKvPrimitives } from "../plugos/lib/indexeddb_kv_primitives.ts" import { DataStoreMQ } from "../plugos/lib/mq.datastore.ts"; import { DataStoreSpacePrimitives } from "../common/spaces/datastore_space_primitives.ts"; import { - encryptedFileExt, EncryptedSpacePrimitives, } from "../common/spaces/encrypted_space_primitives.ts"; const frontMatterRegex = /^---\n(([^\n]|\n)*?)---\n/; @@ -63,7 +62,6 @@ declare global { } } -// TODO: Oh my god, need to refactor this export class Client { system!: ClientSystem; editorView!: EditorView; @@ -501,14 +499,14 @@ export class Client { }, ); - this.eventHook.addLocalListener("file:listed", (fileList: FileMeta[]) => { - this.ui.viewDispatch({ - type: "pages-listed", - pages: fileList.filter(this.space.isListedPage).map( - fileMetaToPageMeta, - ), - }); - }); + // this.eventHook.addLocalListener("file:listed", (fileList: FileMeta[]) => { + // this.ui.viewDispatch({ + // type: "update-all-pages", + // pages: fileList.filter(this.space.isListedPage).map( + // fileMetaToPageMeta, + // ), + // }); + // }); this.space.watch(); @@ -593,6 +591,13 @@ export class Client { ); } + async startPageNavigate() { + // Fetch all pages from the index + const pages = await this.system.queryObjects("page", {}); + // Then show the page navigator + this.ui.viewDispatch({ type: "start-navigate", pages }); + } + private progressTimeout?: number; showProgress(progressPerc: number) { this.ui.viewDispatch({ @@ -719,9 +724,9 @@ export class Client { if (currentNode) { let node: SyntaxNode | null = currentNode; do { - if (node.name === "FencedCode") { + if (node.name === "FencedCode" || node.name === "FrontMatter") { const body = editorState.sliceDoc(node.from + 3, node.to - 3); - parentNodes.push(`FencedCode:${body}`); + parentNodes.push(`${node.name}:${body}`); } else { parentNodes.push(node.name); } diff --git a/web/client_system.ts b/web/client_system.ts index 9844324..d80e658 100644 --- a/web/client_system.ts +++ b/web/client_system.ts @@ -40,6 +40,7 @@ import { codeWidgetSyscalls } from "./syscalls/code_widget.ts"; import { clientCodeWidgetSyscalls } from "./syscalls/client_code_widget.ts"; import { KVPrimitivesManifestCache } from "../plugos/manifest_cache.ts"; import { deepObjectMerge } from "$sb/lib/json.ts"; +import { Query } from "$sb/types.ts"; const plugNameExtractRegex = /\/(.+)\.plug\.js$/; @@ -238,6 +239,14 @@ export class ClientSystem { } localSyscall(name: string, args: any[]) { - return this.system.localSyscall("[local]", name, args); + return this.system.localSyscall("editor", name, args); + } + + queryObjects(tag: string, query: Query): Promise { + return this.system.localSyscall( + "index", + "system.invokeFunction", + ["queryObjects", tag, query], + ); } } diff --git a/web/components/filter.tsx b/web/components/filter.tsx index 0d09d63..b41a4c9 100644 --- a/web/components/filter.tsx +++ b/web/components/filter.tsx @@ -186,15 +186,11 @@ export function FilterList({ )} - ", "")! - // : escapeHtml(option.name), - // }} - > + {option.name} {option.hint && {option.hint}} +
{option.description}
)) : null} diff --git a/web/components/fuse_search.ts b/web/components/fuse_search.ts index f5a6cd6..65ca789 100644 --- a/web/components/fuse_search.ts +++ b/web/components/fuse_search.ts @@ -14,7 +14,12 @@ export const fuzzySearchAndSort = ( return arr.sort((a, b) => (a.orderId || 0) - (b.orderId || 0)); } const enrichedArr: FuseOption[] = arr.map((item) => { - return { ...item, baseName: item.name.split("/").pop()! }; + return { + ...item, + baseName: item.name.split("/").pop()!, + tags: item.tags?.join(" "), + aliases: item.aliases?.join(" "), + }; }); const fuse = new Fuse(enrichedArr, { keys: [{ @@ -23,13 +28,21 @@ export const fuzzySearchAndSort = ( }, { name: "baseName", weight: 0.7, + }, { + name: "displayName", + weight: 0.3, + }, { + name: "tags", + weight: 0.1, + }, { + name: "aliases", + weight: 0.7, }], includeScore: true, shouldSort: true, isCaseSensitive: false, threshold: 0.6, sortFn: (a, b): number => { - // console.log(a, b); if (a.score === b.score) { const aOrder = enrichedArr[a.idx].orderId || 0; const bOrder = enrichedArr[b.idx].orderId || 0; diff --git a/web/components/page_navigator.tsx b/web/components/page_navigator.tsx index 31ca73e..be52ca8 100644 --- a/web/components/page_navigator.tsx +++ b/web/components/page_navigator.tsx @@ -36,8 +36,26 @@ export function PageNavigator({ if (isFederationPath(pageMeta.name)) { orderId = Math.round(orderId / 10); // Just 10x lower the timestamp to push them down, should work } + let description: string | undefined; + let aliases: string[] = []; + if (pageMeta.displayName) { + aliases.push(pageMeta.displayName); + } + if (Array.isArray(pageMeta.aliases)) { + aliases = aliases.concat(pageMeta.aliases); + } + if (aliases.length > 0) { + description = "(a.k.a. " + aliases.join(", ") + ") "; + } + if (pageMeta.tags.length > 1) { + // Every page has the "page" tag, so it only gets interesting beyond that + const interestingTags = pageMeta.tags.filter((tag) => tag !== "page"); + description = (description || "") + + interestingTags.map((tag) => `#${tag}`).join(" "); + } options.push({ ...pageMeta, + description, orderId: orderId, }); } diff --git a/web/editor_state.ts b/web/editor_state.ts index af6477f..772580a 100644 --- a/web/editor_state.ts +++ b/web/editor_state.ts @@ -178,9 +178,7 @@ export function createEditorState( key: "Ctrl-k", mac: "Cmd-k", run: (): boolean => { - client.ui.viewDispatch({ type: "start-navigate" }); - client.space.updatePageList(); - + client.startPageNavigate().catch(console.error); return true; }, }, diff --git a/web/editor_ui.tsx b/web/editor_ui.tsx index fe21e87..5e9ceb3 100644 --- a/web/editor_ui.tsx +++ b/web/editor_ui.tsx @@ -44,7 +44,7 @@ export class MainUI { if (ev.touches.length === 2) { ev.stopPropagation(); ev.preventDefault(); - this.viewDispatch({ type: "start-navigate" }); + client.startPageNavigate().catch(console.error); } // Launch the command palette using a three-finger tap if (ev.touches.length === 3) { @@ -63,7 +63,7 @@ export class MainUI { this.viewState = viewState; this.viewDispatch = dispatch; - const editor = this.client; + const client = this.client; useEffect(() => { if (viewState.currentPage) { @@ -72,8 +72,8 @@ export class MainUI { }, [viewState.currentPage]); useEffect(() => { - editor.tweakEditorDOM( - editor.editorView.contentDOM, + client.tweakEditorDOM( + client.editorView.contentDOM, ); }, [viewState.uiOptions.forcedROMode]); @@ -98,18 +98,18 @@ export class MainUI { {viewState.showPageNavigator && ( { dispatch({ type: "stop-navigate" }); setTimeout(() => { - editor.focus(); + client.focus(); }); if (page) { safeRun(async () => { - await editor.navigate(page); + await client.navigate(page); }); } }} @@ -120,7 +120,7 @@ export class MainUI { onTrigger={(cmd) => { dispatch({ type: "hide-palette" }); setTimeout(() => { - editor.focus(); + client.focus(); }); if (cmd) { dispatch({ type: "command-run", command: cmd.command.name }); @@ -131,14 +131,14 @@ export class MainUI { }) .then(() => { // Always be focusing the editor after running a command - editor.focus(); + client.focus(); }); } }} - commands={editor.getCommandsByContext(viewState)} + commands={client.getCommandsByContext(viewState)} vimMode={viewState.uiOptions.vimMode} darkMode={viewState.uiOptions.darkMode} - completer={editor.miniEditorComplete.bind(editor)} + completer={client.miniEditorComplete.bind(client)} recentCommands={viewState.recentCommands} /> )} @@ -150,7 +150,7 @@ export class MainUI { vimMode={viewState.uiOptions.vimMode} darkMode={viewState.uiOptions.darkMode} allowNew={false} - completer={editor.miniEditorComplete.bind(editor)} + completer={client.miniEditorComplete.bind(client)} helpText={viewState.filterBoxHelpText} onSelect={viewState.filterBoxOnSelect} /> @@ -161,7 +161,7 @@ export class MainUI { defaultValue={viewState.promptDefaultValue} vimMode={viewState.uiOptions.vimMode} darkMode={viewState.uiOptions.darkMode} - completer={editor.miniEditorComplete.bind(editor)} + completer={client.miniEditorComplete.bind(client)} callback={(value) => { dispatch({ type: "hide-prompt" }); viewState.promptCallback!(value); @@ -186,25 +186,25 @@ export class MainUI { vimMode={viewState.uiOptions.vimMode} darkMode={viewState.uiOptions.darkMode} progressPerc={viewState.progressPerc} - completer={editor.miniEditorComplete.bind(editor)} + completer={client.miniEditorComplete.bind(client)} onClick={() => { - editor.editorView.scrollDOM.scrollTop = 0; + client.editorView.scrollDOM.scrollTop = 0; }} onRename={async (newName) => { if (!newName) { // Always move cursor to the start of the page - editor.editorView.dispatch({ + client.editorView.dispatch({ selection: { anchor: 0 }, }); - editor.focus(); + client.focus(); return; } console.log("Now renaming page to...", newName); - await editor.system.system.loadedPlugs.get("index")!.invoke( + await client.system.system.loadedPlugs.get("index")!.invoke( "renamePageCommand", [{ page: newName }], ); - editor.focus(); + client.focus(); }} actionButtons={[ ...!window.silverBulletConfig.syncOnly @@ -242,7 +242,7 @@ export class MainUI { icon: HomeIcon, description: `Go to the index page (Alt-h)`, callback: () => { - editor.navigate(""); + client.navigate(""); }, href: "", }, @@ -250,8 +250,7 @@ export class MainUI { icon: BookIcon, description: `Open page (${isMacLike() ? "Cmd-k" : "Ctrl-k"})`, callback: () => { - dispatch({ type: "start-navigate" }); - editor.space.updatePageList(); + client.startPageNavigate().catch(console.error); }, }, { @@ -260,7 +259,7 @@ export class MainUI { callback: () => { dispatch({ type: "show-palette", - context: editor.getContext(), + context: client.getContext(), }); }, }, @@ -280,11 +279,11 @@ export class MainUI { />
{!!viewState.panels.lhs.mode && ( - + )}
{!!viewState.panels.rhs.mode && ( - + )}
{!!viewState.panels.modal.mode && ( @@ -292,12 +291,12 @@ export class MainUI { className="sb-modal" style={{ inset: `${viewState.panels.modal.mode}px` }} > - +
)} {!!viewState.panels.bhs.mode && (
- +
)} diff --git a/web/reducer.ts b/web/reducer.ts index 7efee62..abe082a 100644 --- a/web/reducer.ts +++ b/web/reducer.ts @@ -11,7 +11,7 @@ export default function reducer( ...state, isLoading: true, currentPage: action.name, - panels: { + panels: state.currentPage === action.name ? state.panels : { ...state.panels, // Hide these by default to avoid flickering top: {}, @@ -45,19 +45,7 @@ export default function reducer( ...state, syncFailures: action.syncSuccess ? 0 : state.syncFailures + 1, }; - case "start-navigate": - return { - ...state, - showPageNavigator: true, - showCommandPalette: false, - showFilterBox: false, - }; - case "stop-navigate": - return { - ...state, - showPageNavigator: false, - }; - case "pages-listed": { + case "start-navigate": { // Let's move over any "lastOpened" times to the "allPages" list const oldPageMeta = new Map( [...state.allPages].map((pm) => [pm.name, pm]), @@ -71,8 +59,17 @@ export default function reducer( return { ...state, allPages: action.pages, + showPageNavigator: true, + showCommandPalette: false, + showFilterBox: false, }; } + case "stop-navigate": + return { + ...state, + showPageNavigator: false, + }; + case "show-palette": { return { ...state, diff --git a/web/styles/colors.scss b/web/styles/colors.scss index 063a7d6..afb08d5 100644 --- a/web/styles/colors.scss +++ b/web/styles/colors.scss @@ -114,9 +114,15 @@ color: var(--modal-selected-option-color); } - .sb-result-list .sb-hint { - color: var(--modal-hint-color); - background-color: var(--modal-hint-background-color); + .sb-result-list { + .sb-hint { + color: var(--modal-hint-color); + background-color: var(--modal-hint-background-color); + } + + .sb-description { + color: var(--modal-description-color); + } } } diff --git a/web/styles/modals.scss b/web/styles/modals.scss index a550484..8cc07e8 100644 --- a/web/styles/modals.scss +++ b/web/styles/modals.scss @@ -61,6 +61,10 @@ position: relative; top: 3px; } + + .sb-description { + font-size: 75%; + } } .sb-option, @@ -81,4 +85,4 @@ padding-bottom: 3px; border-radius: 5px; } -} +} \ No newline at end of file diff --git a/web/styles/theme.scss b/web/styles/theme.scss index b113a6a..3e9aca9 100644 --- a/web/styles/theme.scss +++ b/web/styles/theme.scss @@ -36,6 +36,7 @@ html { --modal-selected-option-color: #eee; --modal-hint-background-color: #212476; --modal-hint-color: #eee; + --modal-description-color: #aaa; --notifications-background-color: inherit; --notifications-border-color: rgb(41, 41, 41); @@ -153,6 +154,7 @@ html[data-theme="dark"] { --modal-selected-option-color: #eee; --modal-hint-background-color: #212476; --modal-hint-color: #eee; + --modal-description-color: #aaa; --notifications-background-color: #333; --notifications-border-color: rgb(197, 197, 197); diff --git a/web/types.ts b/web/types.ts index 6c102cb..95477d0 100644 --- a/web/types.ts +++ b/web/types.ts @@ -5,6 +5,7 @@ import { AppCommand } from "./hooks/command.ts"; // Used by FilterBox export type FilterOption = { name: string; + description?: string; orderId?: number; hint?: string; } & Record; @@ -111,11 +112,10 @@ export const initialViewState: AppViewState = { export type Action = | { type: "page-loaded"; meta: PageMeta } | { type: "page-loading"; name: string } - | { type: "pages-listed"; pages: PageMeta[] } | { type: "page-changed" } | { type: "page-saved" } | { type: "sync-change"; syncSuccess: boolean } - | { type: "start-navigate" } + | { type: "start-navigate"; pages: PageMeta[] } | { type: "stop-navigate" } | { type: "update-commands"; diff --git a/website/Frontmatter.md b/website/Frontmatter.md index fcd82d1..864e70c 100644 --- a/website/Frontmatter.md +++ b/website/Frontmatter.md @@ -15,9 +15,12 @@ Here is an example: ## This is a section This is content -SilverBullet allows arbitrary metadata to be added to pages this way, with two exceptions: +# Special attributes +While SilverBullet allows arbitrary metadata to be added to pages, there are a few attributes with special meaning: -* `name` is an attribute used for page names, so don’t attempt to override it in frontmatter -* `tags` can be specified (as in the example) and are, in effect, another way of adding tags to your page. You can achieve the same result by simply adding hashtags in the body of your document, e.g. `#tag1 #tag2`. +* `name` (==DISALLOWED==): is an attribute used for page names, _you should not set it_. +* `displayName` (`string`): very similar in effect as `aliases` but will use this name for the page in certain contexts. +* `aliases` (`array of strings`): allow you to specify a list of alternative names for this page, which can be used to navigate or link to this page +* `tags` (`array of strings` or `string`): an alternative (and perhaps preferred) way to assign [[Tags]] to a page. In principle you specify them as a list of strings, but for convenience you can also specify them as (possibly comma-separated) string, e.g. `tags: tag1, tag2, tag3` -SilverBullet also has the _convention_ of using attributes starting with a `$` for internal use. For instance, the sharing capability uses the `$share` attribute, and `$disableDirectives: true` has the special meaning of disabling [[🔌 Directive]] processing on a page. \ No newline at end of file +In addition, in the context of [[Templates]] frontmatter has a very specific interpretation. \ No newline at end of file diff --git a/website/Live Templates.md b/website/Live Templates.md index 527045c..0738f9d 100644 --- a/website/Live Templates.md +++ b/website/Live Templates.md @@ -3,29 +3,28 @@ Live templates rendering [[Templates]] inline in a page. ## Syntax Live Templates are specified using [[Markdown]]‘s fenced code block notation using `template` as a language. The body of the code block specifies the template to use, as well as any arguments to pass to it. -Generally you’d use it in one of two ways, either using a `page` template reference, or an inline `template`: +Generally you’d use it in one of two ways, either using a `page` [[Templates|template]] reference, or an inline `template`: Here’s an example using `page`: - ```template page: "[[template/today]]" ``` -And here’s an example using `template`: +And here’s an example using `template`: ```template template: | Today is {{today}}! ``` -To pass in a value to the template, you can specify the optional `value` attribute: +To pass in a value to the template, you can specify the optional `value` attribute: ```template template: | Hello, {{name}}! Today is _{{today}}_ value: name: Pete ``` -If you just want to render the raw markdown without handling it as a handlebars template, set `raw` to true: +If you just want to render the raw markdown without handling it as a handlebars template, set `raw` to true: ```template template: | This is not going to be {{processed}} by Handlebars diff --git a/website/Page Templates.md b/website/Page Templates.md new file mode 100644 index 0000000..eaf2457 --- /dev/null +++ b/website/Page Templates.md @@ -0,0 +1,26 @@ +The {[Page: From Template]} command enables you to create a new page based on a page template. A page template is a [[Templates|template]] with the `type` attribute (in [[Frontmatter]]) set to `page`. + +An example: + + --- + tags: template + type: page + pageName: "📕 " + --- + # {{@page.name}} + As recorded on {{today}}. + + ## Introduction + ## Notes + ## Conclusions + +Will prompt you to pick a page name (defaulting to “📕 “), and then create the following page (on 2023-08-08) when you pick “📕 Harry Potter” as a page name: + + # 📕 Harry Potter + As recorded on 2022-08-08. + + ## Introduction + ## Notes + ## Conclusions + +As with any [[Templates|template]], the `frontmatter` can be used to define [[Frontmatter]] for the new page. \ No newline at end of file diff --git a/website/Plugs/Template.md b/website/Plugs/Template.md index 5faaaed..fd7a34a 100644 --- a/website/Plugs/Template.md +++ b/website/Plugs/Template.md @@ -2,62 +2,6 @@ The [[Plugs/Template]] plug implements a few templating mechanisms. -### Page Templates -> **Warning** Deprecated -> Use [[Slash Templates]] instead - -The {[Template: Instantiate Page]} command enables you to create a new page based on a page template. - -Page templates, by default, are looked for in the `template/page/` prefix. So creating e.g. a `template/page/Meeting Notes` page will create a “Meeting Notes” template. You can override this prefix by setting the `pageTemplatePrefix` in `SETTINGS`. - -Page templates have one “magic” type of page metadata that is used during -instantiation: - -* `$name` is used as the default value for a new page based on this template - -In addition, any standard template placeholders are available (see below) - -For instance: - - --- - $name: "📕 " - --- - - # {{@page.name}} - As recorded on {{today}}. - - ## Introduction - ## Notes - ## Conclusions - -Will prompt you to pick a page name (defaulting to “📕 “), and then create the following page (on 2022-08-08) when you pick “📕 Harry Potter” as a page name: - - # 📕 Harry Potter - As recorded on 2022-08-08. - - ## Introduction - ## Notes - ## Conclusions - -### Snippets -$snippets -> **Warning** Deprecated -> Use [[Slash Templates]] instead - -Snippets are similar to page templates, except you insert them into an existing page with the `/snippet` slash command. The default prefix is `snippet/` which is configurable via the `snippetPrefix` setting in `SETTINGS`. - -Snippet templates do not support the `$name` page meta, because it doesn’t apply. - -However, snippets do support the special `|^|` placeholder for placing the cursor caret after injecting the snippet. If you leave it out, the cursor will simply be placed at the end, but if you like to insert the cursor elsewhere, that position can be set with the `|^|` placeholder. - -For instance to replicate the `/query` slash command as a snippet: - - - - - -Which would insert the cursor right after `#query`. - ### Daily Note The {[Open Daily Note]} command navigates (or creates) a daily note prefixed with a 📅 emoji by default, but this is configurable via the `dailyNotePrefix` setting in `SETTINGS`. If you have a page template (see above) named `template/page/Daily Note` it will use this as a template, otherwise, the page will just be empty (this path is also configurable via the `dailyNoteTemplate` setting). diff --git a/website/SETTINGS.md b/website/SETTINGS.md index 86b7a95..320cb29 100644 --- a/website/SETTINGS.md +++ b/website/SETTINGS.md @@ -7,10 +7,6 @@ indexPage: "[[SilverBullet]]" # Load custom CSS styles from the following page, can also be an array customStyles: "[[STYLES]]" -# Template related settings -pageTemplatePrefix: "template/page/" -snippetPrefix: "snippet/" - quickNotePrefix: "📥 " dailyNotePrefix: "📅 " diff --git a/website/SilverBullet.md b/website/SilverBullet.md index 96521be..5dda116 100644 --- a/website/SilverBullet.md +++ b/website/SilverBullet.md @@ -33,6 +33,7 @@ Some highlights: * SilverBullet runs in any modern browser (including mobile ones) as a [[PWA]] in two [[Client Modes]] ([[Client Modes$online|online]] and [[Client Modes$sync|synced]] mode), where the _synced mode_ enables **100% offline operation**, keeping a copy of content in the browser’s local ([IndexedDB](https://developer.mozilla.org/en-US/docs/Web/API/IndexedDB_API)) database, syncing back to the server when a network connection is available. * SilverBullet provides an enjoyable [[Markdown]] writing experience with a clean UI, rendering text using [[Live Preview|live preview]], further **reducing visual noise** while still providing direct access to the underlying markdown syntax. * SilverBullet supports wiki-style **page linking** using the `[[page link]]` syntax. Incoming links are indexed and appear as [[Linked Mentions]] at the bottom of the pages linked to thereby providing _bi-directional linking_. +* SilverBullet allows you to be extra productive using its [[Templates]] mechanism. * SilverBullet is optimized for **keyboard-based operation**: * Quickly navigate between pages using the **page switcher** (triggered with `Cmd-k` on Mac or `Ctrl-k` on Linux and Windows). * Run commands via their keyboard shortcuts or the **command palette** (triggered with `Cmd-/` or `Ctrl-/` on Linux and Windows). diff --git a/website/Slash Commands.md b/website/Slash Commands.md index f21cfdb..83f3392 100644 --- a/website/Slash Commands.md +++ b/website/Slash Commands.md @@ -5,7 +5,6 @@ The [[Plugs/Editor]] plug provides a few helpful ones: * `/h1` through `/h4` to turn the current line into a header * `/hr` to insert a horizontal rule (`---`) * `/table` to insert a markdown table (whoever can remember this syntax without it) -* `/snippet` see [[Plugs/Template@snippets]] * `/today` to insert today’s date * `/tomorrow` to insert tomorrow’s date diff --git a/website/Slash Templates.md b/website/Slash Templates.md index 088ad36..6f299c2 100644 --- a/website/Slash Templates.md +++ b/website/Slash Templates.md @@ -1,6 +1,6 @@ -Slash templates allow you to define custom [[Slash Commands]] that expand “snippet style” templates inline. They’re like [[🔌 Template$snippets]], but appear immediately as slash commands. +Slash templates allow you to define custom [[Slash Commands]] that expand “snippet style” templates inline. -## Definition +# Definition You can define a slash template by creating a [[Templates|template page]] with a template tag and `trigger` attribute. Example: @@ -13,11 +13,8 @@ Example: |^| -## Use -You can _trigger_ the slash template by typing `/meeting-notes` in any page. That’s it. - ## Frontmatter -A template’s [[Frontmatter]] is interpreted by SilverBullet’s template engine and removed when instantiated. However, to still include frontmatter after instantiation, you can use the `frontmatter` attribute. +A template’s [[Frontmatter]] is interpreted by SilverBullet’s [[Templates|template]] engine and removed when instantiated. However, to still include frontmatter after instantiation, you can use the `frontmatter` attribute. Example: @@ -40,3 +37,7 @@ Which will expand into e.g. . +When the page already contains frontmatter before invoking the slash command, it will be augmented with the additional frontmatter specified by the template. + +# Use +You can _trigger_ the slash template by typing `/` (e.g. `/meeting-notes`) in any page. diff --git a/website/Templates.md b/website/Templates.md index e7e003e..03f32f7 100644 --- a/website/Templates.md +++ b/website/Templates.md @@ -1,25 +1,55 @@ -Templates are _reusable_ pieces of markdown content, usually with placeholders that are replaced once instantiated. +Templates are reusable pieces of markdown content, usually with placeholders that are replaced once instantiated. -Templates are used in a few different contexts: +There are two general uses for templates: -1. To render [[Live Queries]] -2. To render [[Live Templates]] -3. To be included using [[Slash Templates]] -4. Some legacy use cases described in [[Plugs/Template]] +1. _Live_ uses, where page content is dynamically updated based on templates: + * [[Live Queries]] + * [[Live Templates]] +2. _One-off_ uses, where a template is instantiated once and inserted into an existing or new page: + * [[Slash Templates]] + * [[Page Templates]] -## Creating templates -Templates are defined as any other page. It’s convenient, although not required, to use a `template/` prefix when naming templates. It is also _recommended_ to tag templates with a `#template` tag. Note that this tag will be removed when the template is instantiated. +# Creating templates +Templates are regular pages [[Tags|tagged]] with the `#template` tag. Note that, when tagged inline (by putting `#template` at the beginning of the page), the tag will be removed when the template is instantiated. -Tagging a page with a `#template` tag (either in the [[Frontmatter]] or using a [[Tags]] at the very beginning of the page content) does two things: +**Naming**: it’s common, although not required, to use a `template/` prefix when naming templates. -1. It excludes the page from being indexed for [[Objects]], that is: any tasks, items, paragraphs etc. will not appear in your space’s object database. Which is usually what you want. -2. It allows you to register your templates to be used as [[Slash Templates]]. +Tagging a page with a `#template` tag (either in the [[Frontmatter]] or using a [[Tags]] at the very beginning of the page content) does a few things: +1. It will make the page appear when completing template names, e.g. in `render` clauses in [[Live Queries]], or after the `page` key in [[Live Templates]]. +2. It excludes the page from being indexed for [[Objects]], that is: any tasks, items, paragraphs etc. will not appear in your space’s object database. Which is usually what you want. +3. It registers your templates to be used as [[Slash Templates]] as well as [[Page Templates]]. + +## Frontmatter +[[Frontmatter]] has special meaning in templates. The following attributes are used: + +* `tags`: should always be set to `template` +* `type` (optional): should be set to `page` for [[Page Templates]] +* `trigger` (optional): defines the slash command name for [[Slash Templates]] +* `displayName` (optional): defines an alternative name to use when e.g. showing the template picker for [[Page Templates]], or when template completing a `render` clause in a [[Live Templates]]. +* `pageName` (optional, [[Page Templates]] only): specify a (template for a) page name. +* `frontmatter` (optional): defines [[Frontmatter]] to be added/used in the rendered template. This can either be specified as a string or as an object. + +An example: + + --- + tags: template + type: page + trigger: one-on-one + displayName: "1:1 template" + pageName: "1-1s/" + frontmatter: + dateCreated: "{{today}}" + --- + # {{today}} + * |^| + +# Template content Templates consist of markdown, but can also include [Handlebars syntax](https://handlebarsjs.com/), such as `{{today}}`, and `{{#each .}}`. -In addition the special `|^|` marker can be used to specify the desired cursor position after the template is included (relevant mostly to [[Slash Templates]]). +The special `|^|` marker can be used to specify the desired cursor position after the template is included. -### Template helpers +## Handlebar helpers There are a number of built-in handlebars helpers you can use: - `{{today}}`: Today’s date in the usual YYYY-MM-DD format diff --git a/website/template/page/slash-template.md b/website/template/page/slash-template.md new file mode 100644 index 0000000..eb1033b --- /dev/null +++ b/website/template/page/slash-template.md @@ -0,0 +1,9 @@ +--- +tags: template +type: page +displayName: Slash Template +pageName: "template/slash/" +frontmatter: + tags: template + trigger: "|^|" +--- diff --git a/website/template/page/template.md b/website/template/page/template.md new file mode 100644 index 0000000..092ada2 --- /dev/null +++ b/website/template/page/template.md @@ -0,0 +1,10 @@ +--- +tags: template +type: page +displayName: Page Template +pageName: "template/page/" +frontmatter: + tags: template + type: page +--- +|^| \ No newline at end of file diff --git a/website/template/today.md b/website/template/today.md index 7c97914..9ae80ac 100644 --- a/website/template/today.md +++ b/website/template/today.md @@ -1 +1 @@ -Today is {{today}}! \ No newline at end of file +#template Today is {{today}}! \ No newline at end of file