Work on #587: revamped templates
This commit is contained in:
@@ -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[],
|
||||
) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<PageMeta>(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(),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -82,7 +82,7 @@ export async function cacheFileListing(uri: string): Promise<FileMeta[]> {
|
||||
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<FileMeta> {
|
||||
const r = await nativeFetch(url, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
"X-Sync-Mode": "true",
|
||||
"X-Get-Meta": "true",
|
||||
},
|
||||
});
|
||||
|
||||
+20
-3
@@ -72,6 +72,7 @@ export async function indexObjects<T>(
|
||||
const allAttributes = new Map<string, string>(); // 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<T>(
|
||||
// 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<string, any>,
|
||||
)
|
||||
@@ -88,6 +89,18 @@ export async function indexObjects<T>(
|
||||
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<T>(
|
||||
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,
|
||||
};
|
||||
}),
|
||||
|
||||
+42
-17
@@ -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<AttributeCompletion[]> {
|
||||
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<AttributeObject>("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
|
||||
),
|
||||
),
|
||||
};
|
||||
|
||||
+53
-45
@@ -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<string, Record<string, string>> = {
|
||||
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,
|
||||
};
|
||||
}),
|
||||
|
||||
@@ -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"));
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
+32
-2
@@ -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<LintDiagnostic[]> {
|
||||
const diagnostics: LintDiagnostic[] = [];
|
||||
const frontmatter = await extractFrontmatter(tree);
|
||||
// Query all readOnly attributes for pages with this tag set
|
||||
const readOnlyAttributes = await queryObjects<AttributeObject>("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<LintDiagnostic | undefined> {
|
||||
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) {
|
||||
|
||||
+4
-4
@@ -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<PageMeta>(name, [pageMeta]);
|
||||
|
||||
+1
-1
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<TemplateObject> = 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,
|
||||
}),
|
||||
|
||||
@@ -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<SlashCompletion[]> {
|
||||
const allTemplates = await queryObjects<TemplateObject>("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",
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
+55
-171
@@ -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<SlashCompletion[]> {
|
||||
const allTemplates = await queryObjects<TemplateObject>("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<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')`,
|
||||
);
|
||||
|
||||
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 <tt>${pageTemplatePrefix}</tt>)`,
|
||||
);
|
||||
|
||||
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 <tt>${snippetPrefix}</tt>)`,
|
||||
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 <tt>${pageTemplatePrefix}</tt>)`,
|
||||
);
|
||||
|
||||
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<PageMeta> {
|
||||
|
||||
@@ -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, any> | string;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user