1
0

Awesome frontmatter (#617)

Live Frontmatter Templates
This commit is contained in:
Zef Hemel 2024-01-04 20:08:12 +01:00 committed by GitHub
parent 9040993232
commit 91027af5fe
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
53 changed files with 646 additions and 342 deletions

3
.gitignore vendored
View File

@ -14,4 +14,5 @@ node_modules
*.db*
test_space
silverbullet
deploy.json
deploy.json
*.generated

37
common/json.test.ts Normal file
View File

@ -0,0 +1,37 @@
import { assertEquals } from "../test_deps.ts";
import { traverseAndRewriteJSON } from "./json.ts";
Deno.test("traverseAndRewrite should recursively traverse and rewrite object properties", () => {
const bufArray = new Uint8Array([1, 2, 3]);
const obj = {
foo: "bar",
list: ["hello", { sup: "world" }],
nested: {
baz: "qux",
},
special: {
value: () => {
return bufArray;
},
},
};
const rewritten = traverseAndRewriteJSON(obj, (val) => {
if (typeof val?.value === "function") {
return val.value();
}
if (typeof val === "string") {
return val.toUpperCase();
}
return val;
});
assertEquals(rewritten, {
foo: "BAR",
list: ["HELLO", { sup: "WORLD" }],
nested: {
baz: "QUX",
},
special: bufArray,
});
});

26
common/json.ts Normal file
View File

@ -0,0 +1,26 @@
/**
* Traverses and rewrites an object recursively.
*
* @param obj - The object to traverse and rewrite.
* @param rewrite - The function to apply for rewriting each value.
* @returns The rewritten object.
*/
export function traverseAndRewriteJSON(
obj: any,
rewrite: (val: any) => any,
): any {
// Apply rewrite to object as a whole
obj = rewrite(obj);
// Recurse down if this is an array or a "plain object"
if (
obj && Array.isArray(obj) ||
(typeof obj === "object" && obj.constructor === Object)
) {
const keys = Object.keys(obj);
for (let i = 0; i < keys.length; i++) {
const key = keys[i];
obj[key] = traverseAndRewriteJSON(obj[key], rewrite);
}
}
return obj;
}

View File

@ -25,7 +25,10 @@ import {
xmlLanguage,
yamlLanguage,
} from "./deps.ts";
import { highlightingQueryParser } from "./markdown_parser/parser.ts";
import {
expressionParser,
highlightingQueryParser,
} from "./markdown_parser/parser.ts";
export const builtinLanguages: Record<string, Language> = {
"meta": StreamLanguage.define(yamlLanguage),
@ -81,6 +84,10 @@ export const builtinLanguages: Record<string, Language> = {
name: "query",
parser: highlightingQueryParser,
}),
"expression": LRLanguage.define({
name: "expression",
parser: expressionParser,
}),
};
export function languageFor(name: string): Language | null {

View File

@ -1,119 +0,0 @@
@top Program { Expression }
@precedence {
mulop @left
addop @left
binop @left
and @left
or @left
}
@skip {
space
}
commaSep<content> { content ("," content)* }
kw<term> { @specialize[@name={term}]<Identifier, term> }
Query {
TagIdentifier ( WhereClause | LimitClause | OrderClause | SelectClause | RenderClause )*
}
WhereClause { kw<"where"> Expression }
LimitClause { kw<"limit"> Expression }
OrderClause { Order commaSep<OrderBy> }
OrderBy { Expression OrderDirection? }
SelectClause { kw<"select"> commaSep<Select> }
RenderClause { kw<"render"> ( kw<"each"> | kw<"all"> )? PageRef }
Select { Identifier | Expression kw<"as"> Identifier }
OrderDirection {
OrderKW
}
Value { Number | String | Bool | Regex | kw<"null"> | List }
Attribute {
LVal "." Identifier
}
Call {
Identifier "(" commaSep<Expression> ")" | Identifier "(" ")"
}
LVal {
Identifier
| Attribute
}
ParenthesizedExpression { "(" Expression ")" }
LogicalExpression {
Expression !and kw<"and"> Expression
| Expression !or kw<"or"> Expression
}
Expression {
Value
| LVal
| ParenthesizedExpression
| LogicalExpression
| BinExpression
| Call
}
BinExpression {
Expression !binop "<" Expression
| Expression !binop "<=" Expression
| Expression !binop "=" Expression
| Expression !binop "!=" Expression
| Expression !binop ">=" Expression
| Expression !binop ">" Expression
| Expression !binop "=~" Expression
| Expression !binop "!=~" Expression
| Expression !binop InKW Expression
| Expression !mulop "*" Expression
| Expression !mulop "/" Expression
| Expression !mulop "%" Expression
| Expression !addop "+" Expression
| Expression !addop "-" Expression
}
List { "[" commaSep<Expression> "]" }
Bool {
BooleanKW
}
@tokens {
space { std.whitespace+ }
TagIdentifier { @asciiLetter (@asciiLetter | @digit | "-" | "_" | "/" )* }
Identifier { @asciiLetter (@asciiLetter | @digit | "-" | "_")* }
String {
("\"" | "“" | "”") ![\"”“]* ("\"" | "“" | "”")
}
PageRef {
"[" "[" ![\]]* "]" "]"
}
Order { "order by" }
Regex { "/" ( ![/\\\n\r] | "\\" _ )* "/"? }
Number { std.digit+ }
BooleanKW { "true" | "false" }
InKW { "in" }
OrderKW { "asc" | "desc" }
@precedence { Order, BooleanKW, InKW, OrderKW, Identifier, Number }
}

View File

@ -13,6 +13,6 @@ export const parser = LRParser.deserialize({
tokenData: "/j~RuX^#fpq#fqr$Zrs$nuv%fxy%kyz%pz{%u{|%z|}&P}!O&U!O!P&Z!P!Q&`!Q![)Q!^!_)Y!_!`)g!`!a)t!c!}*R!}#O*g#P#Q*l#T#Y*R#Y#Z*q#Z#]*R#]#^-b#^#h*R#h#i.d#i#o*R#y#z#f$f$g#f#BY#BZ#f$IS$I_#f$Ip$Iq$n$Iq$Ir$n$I|$JO#f$JT$JU#f$KV$KW#f&FU&FV#f~#kYh~X^#fpq#f#y#z#f$f$g#f#BY#BZ#f$IS$I_#f$I|$JO#f$JT$JU#f$KV$KW#f&FU&FV#f~$^P!_!`$a~$fPs~#r#s$i~$nOw~~$qWOr$nrs%Zs$Ip$n$Ip$Iq%Z$Iq$Ir%Z$Ir;'S$n;'S;=`%`<%lO$n~%`OT~~%cP;=`<%l$n~%kOz~~%pOn~~%uOo~~%zOx~~&PO{~~&UOk~~&ZO|~~&`Om~R&gXyQWPOY'SZ]'S^!P'S!P!Q't!Q#O'S#O#P'y#P;'S'S;'S;=`(z<%lO'SP'XXWPOY'SZ]'S^!P'S!P!Q't!Q#O'S#O#P'y#P;'S'S;'S;=`(z<%lO'SP'yOWPP'|RO;'S'S;'S;=`(V;=`O'SP([YWPOY'SZ]'S^!P'S!P!Q't!Q#O'S#O#P'y#P;'S'S;'S;=`(z;=`<%l'S<%lO'SP(}P;=`<%l'S~)VPS~!Q![)Q~)_Pp~!_!`)b~)gOq~~)lPr~#r#s)o~)tOv~~)yPu~!_!`)|~*ROt~~*WTX~}!O*R!Q![*R!c!}*R#R#S*R#T#o*R~*lOi~~*qOl~~*vUX~}!O*R!Q![*R!c!}*R#R#S*R#T#U+Y#U#o*R~+_VX~}!O*R!Q![*R!c!}*R#R#S*R#T#`*R#`#a+t#a#o*R~+yVX~}!O*R!Q![*R!c!}*R#R#S*R#T#g*R#g#h,`#h#o*R~,eVX~}!O*R!Q![*R!c!}*R#R#S*R#T#X*R#X#Y,z#Y#o*R~-RTV~X~}!O*R!Q![*R!c!}*R#R#S*R#T#o*R~-gVX~}!O*R!Q![*R!c!}*R#R#S*R#T#b*R#b#c-|#c#o*R~.TTc~X~}!O*R!Q![*R!c!}*R#R#S*R#T#o*R~.iVX~}!O*R!Q![*R!c!}*R#R#S*R#T#f*R#f#g/O#g#o*R~/TVX~}!O*R!Q![*R!c!}*R#R#S*R#T#i*R#i#j,`#j#o*R",
tokenizers: [0, 1],
topRules: {"Program":[0,1]},
specialized: [{term: 9, get: value => spec_Identifier[value] || -1}],
specialized: [{term: 9, get: (value) => spec_Identifier[value] || -1}],
tokenPrec: 431
})

View File

@ -13,6 +13,6 @@ export const parser = LRParser.deserialize({
tokenData: "8k~RzX^#upq#uqr$jrs$}uv%uxy%zyz&Pz{&U{|&Z|}&`}!O&e!O!P&j!P!Q&o!Q![)a!^!_)i!_!`)v!`!a*T!c!}*b!}#O+d#P#Q,a#T#U,f#U#W*b#W#X.c#X#Y*b#Y#Z/S#Z#]*b#]#^2^#^#c*b#c#d3j#d#h*b#h#i7Z#i#o*b#y#z#u$f$g#u#BY#BZ#u$IS$I_#u$Ip$Iq$}$Iq$Ir$}$I|$JO#u$JT$JU#u$KV$KW#u&FU&FV#u~#zY!P~X^#upq#u#y#z#u$f$g#u#BY#BZ#u$IS$I_#u$I|$JO#u$JT$JU#u$KV$KW#u&FU&FV#u~$mP!_!`$p~$uP![~#r#s$x~$}O!`~~%QWOr$}rs%js$Ip$}$Ip$Iq%j$Iq$Ir%j$Ir;'S$};'S;=`%o<%lO$}~%oOY~~%rP;=`<%l$}~%zO!c~~&PO!V~~&UO!W~~&ZO!a~~&`O!d~~&eO!S~~&jO!e~~&oO!U~U&vX!bS]QOY'cZ]'c^!P'c!P!Q(T!Q#O'c#O#P(Y#P;'S'c;'S;=`)Z<%lO'cQ'hX]QOY'cZ]'c^!P'c!P!Q(T!Q#O'c#O#P(Y#P;'S'c;'S;=`)Z<%lO'cQ(YO]QQ(]RO;'S'c;'S;=`(f;=`O'cQ(kY]QOY'cZ]'c^!P'c!P!Q(T!Q#O'c#O#P(Y#P;'S'c;'S;=`)Z;=`<%l'c<%lO'cQ)^P;=`<%l'c~)fPX~!Q![)a~)nP!X~!_!`)q~)vO!Y~~){P!Z~#r#s*O~*TO!_~~*YP!^~!_!`*]~*bO!]~V*iURPTU}!O*b!P!Q*{!Q![*b!c!}*b#R#S*b#T#o*bP+QURP}!O*{!P!Q*{!Q![*{!c!}*{#R#S*{#T#o*{V+iP!QQ!}#O+lT+oTO#P+l#P#Q,O#Q;'S+l;'S;=`,Z<%lO+lT,RP#P#Q,UT,ZOxTT,^P;=`<%l+l~,fO!T~V,mWRPTU}!O*b!P!Q*{!Q![*b!c!}*b#R#S*b#T#g*b#g#h-V#h#o*bV-^WRPTU}!O*b!P!Q*{!Q![*b!c!}*b#R#S*b#T#V*b#V#W-v#W#o*bV.PURPoSTU}!O*b!P!Q*{!Q![*b!c!}*b#R#S*b#T#o*bV.jWRPTU}!O*b!P!Q*{!Q![*b!c!}*b#R#S*b#T#X*b#X#Y,f#Y#o*bV/ZVRPTU}!O*b!P!Q*{!Q![*b!c!}*b#R#S*b#T#U/p#U#o*bV/wWRPTU}!O*b!P!Q*{!Q![*b!c!}*b#R#S*b#T#`*b#`#a0a#a#o*bV0hWRPTU}!O*b!P!Q*{!Q![*b!c!}*b#R#S*b#T#g*b#g#h1Q#h#o*bV1XWRPTU}!O*b!P!Q*{!Q![*b!c!}*b#R#S*b#T#X*b#X#Y1q#Y#o*bV1zURP[QTU}!O*b!P!Q*{!Q![*b!c!}*b#R#S*b#T#o*bV2eWRPTU}!O*b!P!Q*{!Q![*b!c!}*b#R#S*b#T#b*b#b#c2}#c#o*bV3WURPgSTU}!O*b!P!Q*{!Q![*b!c!}*b#R#S*b#T#o*bV3qWRPTU}!O*b!P!Q*{!Q![*b!c!}*b#R#S*b#T#f*b#f#g4Z#g#o*bV4bWRPTU}!O*b!P!Q*{!Q![*b!c!}*b#R#S*b#T#W*b#W#X4z#X#o*bV5RWRPTU}!O*b!P!Q*{!Q![*b!c!}*b#R#S*b#T#X*b#X#Y5k#Y#o*bV5rWRPTU}!O*b!P!Q*{!Q![*b!c!}*b#R#S*b#T#f*b#f#g6[#g#o*bV6cVRPTUpq6x}!O*b!P!Q*{!Q![*b!c!}*b#R#S*b#T#o*bU6{P#U#V7OU7RP#m#n7UU7ZOlUV7bWRPTU}!O*b!P!Q*{!Q![*b!c!}*b#R#S*b#T#f*b#f#g7z#g#o*bV8RWRPTU}!O*b!P!Q*{!Q![*b!c!}*b#R#S*b#T#i*b#i#j1Q#j#o*b",
tokenizers: [0, 1, 2],
topRules: {"Program":[0,1]},
specialized: [{term: 5, get: value => spec_Identifier[value] || -1}],
specialized: [{term: 5, get: (value) => spec_Identifier[value] || -1}],
tokenPrec: 798
})

View File

@ -136,3 +136,9 @@ Deno.test("Test command link arguments", () => {
const args2 = findNodeOfType(commands[1], "CommandLinkArgs");
assertEquals(args2!.children![0].text, '"other", "args", 123');
});
Deno.test("Test template directives", () => {
const lang = buildMarkdown([]);
const tree = parse(lang, `Hello there {{name}}!`);
console.log("Template directive", JSON.stringify(tree, null, 2));
});

View File

@ -135,6 +135,38 @@ const CommandLink: MarkdownConfig = {
],
};
export const templateDirectiveRegex = /^\{\{([^\}]+)\}\}/;
const TemplateDirective: MarkdownConfig = {
defineNodes: [
{ name: "TemplateDirective", style: t.monospace },
{ name: "TemplateDirectiveMark", style: t.monospace },
],
parseInline: [
{
name: "TemplateDirective",
parse(cx, next, pos) {
let match: RegExpMatchArray | null;
if (
next != 123 /* '{' */ ||
!(match = templateDirectiveRegex.exec(cx.slice(pos, cx.end)))
) {
return -1;
}
const fullMatch = match[0];
const endPos = pos + fullMatch.length;
return cx.addElement(
cx.elt("TemplateDirective", pos, endPos, [
cx.elt("TemplateDirectiveMark", pos, pos + 2),
cx.elt("TemplateDirectiveMark", endPos - 2, endPos),
]),
);
},
after: "Emphasis",
},
],
};
const HighlightDelim = { resolve: "Highlight", mark: "HighlightMark" };
export const Highlight: MarkdownConfig = {
@ -175,6 +207,8 @@ export const highlightingQueryParser = queryParser.configure({
],
});
export { parser as expressionParser } from "./parse-expression.js";
export const attributeStartRegex = /^\[([\w\$]+)(::?\s*)/;
export const Attribute: MarkdownConfig = {
@ -355,6 +389,7 @@ export default function buildMarkdown(mdExtensions: MDExt[]): Language {
TaskList,
Comment,
Highlight,
TemplateDirective,
Strikethrough,
Table,
...mdExtensions.map(mdExtensionSyntaxConfig),

View File

@ -40,5 +40,17 @@ export function handlebarHelpers() {
nextWeek.setDate(nextWeek.getDate() + 7);
return niceDate(nextWeek);
},
ifEq: function (v1: any, v2: any, options: any) {
if (v1 === v2) {
return options.fn(this);
}
return options.inverse(this);
},
ifNeq: function (v1: any, v2: any, options: any) {
if (v1 !== v2) {
return options.fn(this);
}
return options.inverse(this);
},
};
}

View File

@ -4,12 +4,20 @@ import { SpacePrimitives } from "./spaces/space_primitives.ts";
import { expandPropertyNames } from "$sb/lib/json.ts";
import type { BuiltinSettings } from "../web/types.ts";
/**
* Runs a function safely by catching any errors and logging them to the console.
* @param fn - The function to run.
*/
export function safeRun(fn: () => Promise<void>) {
fn().catch((e) => {
console.error(e);
});
}
/**
* Checks if the current platform is Mac-like (Mac, iPhone, iPod, iPad).
* @returns A boolean indicating if the platform is Mac-like.
*/
export function isMacLike() {
return /(Mac|iPhone|iPod|iPad)/i.test(navigator.platform);
}
@ -17,6 +25,11 @@ export function isMacLike() {
// TODO: This is naive, may be better to use a proper parser
const yamlSettingsRegex = /```yaml([^`]+)```/;
/**
* Parses YAML settings from a Markdown string.
* @param settingsMarkdown - The Markdown string containing the YAML settings.
* @returns An object representing the parsed YAML settings.
*/
export function parseYamlSettings(settingsMarkdown: string): {
[key: string]: any;
} {
@ -35,6 +48,12 @@ export function parseYamlSettings(settingsMarkdown: string): {
}
}
/**
* Ensures that the settings and index page exist in the given space.
* If they don't exist, default settings and index page will be created.
* @param space - The SpacePrimitives object representing the space.
* @returns A promise that resolves to the built-in settings.
*/
export async function ensureSettingsAndIndex(
space: SpacePrimitives,
): Promise<BuiltinSettings> {

View File

@ -1,5 +1,6 @@
import type { AST } from "$sb/lib/tree.ts";
import { type AST, parseTreeToAST } from "$sb/lib/tree.ts";
import type { Query, QueryExpression } from "$sb/types.ts";
import { language } from "$sb/syscalls.ts";
export function astToKvQuery(
node: AST,
@ -20,10 +21,10 @@ export function astToKvQuery(
query.filter = [
"and",
query.filter,
expressionToKvQueryFilter(clause[2]),
expressionToKvQueryExpression(clause[2]),
];
} else {
query.filter = expressionToKvQueryFilter(clause[2]);
query.filter = expressionToKvQueryExpression(clause[2]);
}
break;
}
@ -123,18 +124,18 @@ export function expressionToKvQueryExpression(node: AST): QueryExpression {
}
case "BinExpression": {
const lval = expressionToKvQueryExpression(node[1]);
const binOp = (node[2] as string).trim();
const binOp = node[2][0] === "InKW" ? "in" : (node[2] as string).trim();
const val = expressionToKvQueryExpression(node[3]);
return [binOp as any, lval, val];
}
case "LogicalExpression": {
const op1 = expressionToKvQueryFilter(node[1]);
const op1 = expressionToKvQueryExpression(node[1]);
const op = node[2];
const op2 = expressionToKvQueryFilter(node[3]);
const op2 = expressionToKvQueryExpression(node[3]);
return [op[1] as any, op1, op2];
}
case "ParenthesizedExpression": {
return expressionToKvQueryFilter(node[2]);
return expressionToKvQueryExpression(node[2]);
}
case "Call": {
// console.log("Call", node);
@ -151,33 +152,12 @@ export function expressionToKvQueryExpression(node: AST): QueryExpression {
throw new Error(`Not supported: ${node[0]}`);
}
}
function expressionToKvQueryFilter(
node: AST,
): QueryExpression {
const [expressionType] = node;
if (expressionType === "Expression") {
return expressionToKvQueryFilter(node[1]);
}
switch (expressionType) {
case "BinExpression": {
const lval = expressionToKvQueryExpression(node[1]);
const binOp = node[2][0] === "InKW" ? "in" : (node[2] as string).trim();
const val = expressionToKvQueryExpression(node[3]);
return [binOp as any, lval, val];
}
case "LogicalExpression": {
// console.log("Logical expression", node);
// 0 = first operand, 1 = whitespace, 2 = operator, 3 = whitespace, 4 = second operand
const op1 = expressionToKvQueryFilter(node[1]);
const op = node[2]; // 1 is whitespace
const op2 = expressionToKvQueryFilter(node[3]);
return [op[1] as any, op1, op2];
}
case "ParenthesizedExpression": {
return expressionToKvQueryFilter(node[2]);
}
default:
throw new Error(`Unknown expression type: ${expressionType}`);
}
export async function parseQuery(query: string): Promise<Query> {
const queryAST = parseTreeToAST(
await language.parseLanguage(
"query",
query,
),
);
return astToKvQuery(queryAST[1]);
}

View File

@ -21,6 +21,14 @@ Deno.test("Test query parser", () => {
},
);
assertEquals(
astToKvQuery(wrapQueryParse(`page where true`)!),
{
querySource: "page",
filter: ["boolean", true],
},
);
assertEquals(
astToKvQuery(wrapQueryParse(`page where name =~ /test/`)!),
{

View File

@ -1,4 +1,3 @@
import { ParseTree, renderToText, replaceNodesMatching } from "$sb/lib/tree.ts";
import { FunctionMap, KV, Query, QueryExpression } from "$sb/types.ts";
export function evalQueryExpression(

View File

@ -134,9 +134,11 @@ export type CodeWidgetContent = {
markdown?: string;
script?: string;
buttons?: CodeWidgetButton[];
banner?: string;
};
export type CodeWidgetButton = {
widgetTarget?: boolean;
description: string;
svg: string;
invokeFunction: string;

View File

@ -11,13 +11,13 @@ export const builtins: Record<string, Record<string, string>> = {
ref: "!string",
name: "!string",
displayName: "string",
aliases: "array",
aliases: "string[]",
created: "!date",
lastModified: "!date",
perm: "!rw|ro",
contentType: "!string",
size: "!number",
tags: "array",
tags: "string[]",
},
task: {
ref: "!string",
@ -27,11 +27,11 @@ export const builtins: Record<string, Record<string, string>> = {
state: "!string",
deadline: "string",
pos: "!number",
tags: "array",
tags: "string[]",
},
taskstate: {
ref: "!string",
tags: "!array",
tags: "!string[]",
state: "!string",
count: "!number",
page: "!string",
@ -76,6 +76,7 @@ export const builtins: Record<string, Record<string, string>> = {
pos: "!number",
type: "string",
trigger: "string",
forTags: "string[]",
},
};

View File

@ -0,0 +1,82 @@
import { CodeWidgetContent } from "$sb/types.ts";
import { editor, language, markdown, space } from "$sb/syscalls.ts";
import { extractFrontmatter } from "$sb/lib/frontmatter.ts";
import { queryObjects } from "./api.ts";
import { TemplateObject } from "../template/types.ts";
import { renderTemplate } from "../template/plug_api.ts";
import { loadPageObject } from "../template/template.ts";
import { expressionToKvQueryExpression } from "$sb/lib/parse-query.ts";
import { evalQueryExpression } from "$sb/lib/query.ts";
import { parseTreeToAST } from "$sb/lib/tree.ts";
// Somewhat decent looking default template
const fallbackTemplate = `{{#each .}}
{{#ifEq @key "tags"}}{{else}}**{{@key}}**: {{.}}
{{/ifEq}}
{{/each}}
{{#if tags}}_Tagged with_ {{#each tags}}#{{.}} {{/each}}{{/if}}`;
export async function renderFrontmatterWidget(): Promise<
CodeWidgetContent | null
> {
const text = await editor.getText();
const pageMeta = await loadPageObject(await editor.getCurrentPage());
const parsedMd = await markdown.parseMarkdown(text);
const frontmatter = await extractFrontmatter(parsedMd);
const allFrontMatterTemplates = await queryObjects<TemplateObject>(
"template",
{
filter: ["=", ["attr", "type"], ["string", "frontmatter"]],
orderBy: [{ expr: ["attr", "priority"], desc: false }],
},
);
let templateText = fallbackTemplate;
// Strategy: walk through all matching templates, evaluate the 'where' expression, and pick the first one that matches
for (const template of allFrontMatterTemplates) {
const exprAST = parseTreeToAST(
await language.parseLanguage("expression", template.where!),
);
const parsedExpression = expressionToKvQueryExpression(exprAST[1]);
if (evalQueryExpression(parsedExpression, pageMeta)) {
// Match! We're happy
templateText = await space.readPage(template.ref);
break;
}
}
const summaryText = await renderTemplate(
templateText,
pageMeta,
frontmatter,
);
// console.log("Rendered", summaryText);
return {
markdown: summaryText.text,
banner: "frontmatter",
buttons: [
{
description: "Reload",
svg:
`<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-refresh-cw"><polyline points="23 4 23 10 17 10"></polyline><polyline points="1 20 1 14 7 14"></polyline><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"></path></svg>`,
invokeFunction: "index.refreshWidgets",
},
{
description: "Edit",
svg:
`<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-edit"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"></path></svg>`,
invokeFunction: "index.editFrontmatter",
},
{
description: "",
svg: "",
widgetTarget: true,
invokeFunction: "index.editFrontmatter",
},
],
};
}
export async function editFrontmatter() {
// 4 = after the frontmatter (--- + newline)
await editor.moveCursor(4, true);
}

View File

@ -186,4 +186,13 @@ functions:
lintYAML:
path: lint.ts:lintYAML
events:
- editor:lint
- editor:lint
renderFrontmatterWidget:
path: frontmatter.ts:renderFrontmatterWidget
env: client
panelWidget: frontmatter
editFrontmatter:
path: frontmatter.ts:editFrontmatter

View File

@ -48,7 +48,7 @@ export async function renderTOC(): Promise<CodeWidgetContent | null> {
return false;
});
if (headers.length < headerThreshold) {
console.log("Not enough headers, not showing TOC", headers.length);
// Not enough headers, not showing TOC
return null;
}
// console.log("Headers", headers);

View File

@ -267,7 +267,10 @@ function render(
}
case "Hashtag":
return {
name: "strong",
name: "span",
attrs: {
class: "hashtag",
},
body: t.children![0].text!,
};
@ -414,6 +417,16 @@ function render(
case "Entity":
return t.children![0].text!;
case "TemplateDirective": {
return {
name: "span",
attrs: {
class: "template-directive",
},
body: renderToText(t),
};
}
// Text
case undefined:
return t.text!;

View File

@ -1,11 +1,7 @@
import type { LintEvent } from "$sb/app_event.ts";
import { events, language, space } from "$sb/syscalls.ts";
import {
findNodeOfType,
parseTreeToAST,
traverseTreeAsync,
} from "$sb/lib/tree.ts";
import { astToKvQuery } from "$sb/lib/parse-query.ts";
import { events, space } from "$sb/syscalls.ts";
import { findNodeOfType, traverseTreeAsync } from "$sb/lib/tree.ts";
import { parseQuery } from "$sb/lib/parse-query.ts";
import { loadPageObject, replaceTemplateVars } from "../template/template.ts";
import { cleanPageRef, resolvePath } from "$sb/lib/resolve.ts";
import { CodeWidgetContent, LintDiagnostic } from "$sb/types.ts";
@ -18,13 +14,9 @@ export async function widget(
const pageObject = await loadPageObject(pageName);
try {
const queryAST = parseTreeToAST(
await language.parseLanguage(
"query",
await replaceTemplateVars(bodyText, pageObject),
),
const parsedQuery = await parseQuery(
await replaceTemplateVars(bodyText, pageObject),
);
const parsedQuery = astToKvQuery(queryAST[1]);
if (!parsedQuery.limit) {
parsedQuery.limit = ["number", 1000];
@ -115,13 +107,10 @@ export async function lintQuery(
}
const bodyText = codeText.children![0].text!;
try {
const queryAST = parseTreeToAST(
await language.parseLanguage(
"query",
await replaceTemplateVars(bodyText, pageObject),
),
const parsedQuery = await parseQuery(
await replaceTemplateVars(bodyText, pageObject),
);
const parsedQuery = astToKvQuery(queryAST[1]);
const allSources = await allQuerySources();
if (
parsedQuery.querySource &&
@ -141,7 +130,7 @@ export async function lintQuery(
);
try {
await space.getPageMeta(templatePage);
} catch (e: any) {
} catch {
diagnostics.push({
from: codeText.from!,
to: codeText.to!,

View File

@ -1,11 +1,17 @@
import { ObjectValue } from "$sb/types.ts";
export type TemplateFrontmatter = {
trigger?: string; // slash command name
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;
// Specific for slash templates
trigger?: string;
// Specific for frontmatter templates
where?: string; // expression (SB query style)
priority?: number; // When multiple templates match, the one with the highest priority is used
};
export type TemplateObject = ObjectValue<TemplateFrontmatter>;

View File

@ -36,20 +36,27 @@ export function buildHandebarOptions(pageMeta: PageMeta) {
};
}
export function defaultJsonTransformer(_k: string, v: any) {
export function defaultJsonTransformer(v: any): string {
if (v === undefined) {
return "";
}
if (typeof v === "string") {
return v.replaceAll("\n", " ").replaceAll("|", "\\|");
}
if (Array.isArray(v)) {
return v.map(defaultJsonTransformer).join(", ");
} else if (typeof v === "object") {
return Object.entries(v).map(([k, v]: [string, any]) =>
`${k}: ${defaultJsonTransformer(v)}`
).join(", ");
}
return "" + v;
}
// Nicely format an array of JSON objects as a Markdown table
export function jsonToMDTable(
jsonArray: any[],
valueTransformer: (k: string, v: any) => string = defaultJsonTransformer,
valueTransformer: (v: any) => string = defaultJsonTransformer,
): string {
const headers = new Set<string>();
for (const entry of jsonArray) {
@ -79,7 +86,7 @@ export function jsonToMDTable(
for (const val of jsonArray) {
const el = [];
for (const prop of headerList) {
const s = valueTransformer(prop, val[prop]);
const s = valueTransformer(val[prop]);
el.push(s);
}
lines.push("|" + el.join("|") + "|");

View File

@ -1,9 +1,12 @@
#!/bin/sh -e
QUERY_GRAMMAR=common/markdown_parser/query.grammar
EXPRESSION_GRAMMAR=common/markdown_parser/expression.grammar
EXPRESSION_GRAMMAR=common/markdown_parser/expression.grammar.generated
LEZER_GENERATOR_VERSION=1.5.1
# Generate a patched grammer for just expressions
echo "@top Program { Expression }" > $EXPRESSION_GRAMMAR
tail -n +2 $QUERY_GRAMMAR >> $EXPRESSION_GRAMMAR
npx lezer-generator $QUERY_GRAMMAR -o common/markdown_parser/parse-query.js
npx lezer-generator $EXPRESSION_GRAMMAR -o common/markdown_parser/parse-expression.js
deno run -A npm:@lezer/generator@$LEZER_GENERATOR_VERSION $QUERY_GRAMMAR -o common/markdown_parser/parse-query.js
deno run -A npm:@lezer/generator@$LEZER_GENERATOR_VERSION $EXPRESSION_GRAMMAR -o common/markdown_parser/parse-expression.js

View File

@ -289,7 +289,7 @@ export class Client {
this.pageNavigator.subscribe(
async (pageName, pos: number | string | undefined) => {
console.log("Now navigating to", pageName, pos);
console.log("Now navigating to", pageName);
const stateRestored = await this.loadPage(pageName, pos === undefined);
if (pos) {
@ -1012,7 +1012,6 @@ export class Client {
// Widget and image height caching
private widgetCache = new LimitedMap<WidgetCacheItem>(100); // bodyText -> WidgetCacheItem
private imageHeightCache = new LimitedMap<number>(100); // url -> height
private widgetHeightCache = new LimitedMap<number>(100); // bodytext -> height
async loadCaches() {
@ -1066,4 +1065,5 @@ type WidgetCacheItem = {
height: number;
html: string;
buttons?: CodeWidgetButton[];
banner?: string;
};

View File

@ -1,76 +1,38 @@
import { Decoration, EditorState, foldedRanges, syntaxTree } from "../deps.ts";
import { Decoration, EditorState, syntaxTree } from "../deps.ts";
import {
decoratorStateField,
HtmlWidget,
invisibleDecoration,
isCursorInRange,
} from "./util.ts";
function hideNodes(state: EditorState) {
const widgets: any[] = [];
const foldRanges = foldedRanges(state);
syntaxTree(state).iterate({
enter(node) {
if (
node.name === "HorizontalRule" &&
!isCursorInRange(state, [node.from, node.to])
) {
widgets.push(invisibleDecoration.range(node.from, node.to));
widgets.push(
Decoration.line({
class: "sb-line-hr",
}).range(node.from),
);
}
if (
node.name === "Image" &&
!isCursorInRange(state, [node.from, node.to])
) {
widgets.push(invisibleDecoration.range(node.from, node.to));
}
if (
node.name === "FrontMatterMarker"
) {
const parent = node.node.parent!;
const folded = foldRanges.iter();
let shouldShowFrontmatterBanner = false;
while (folded.value) {
// Check if cursor is in the folded range
if (isCursorInRange(state, [folded.from, folded.to])) {
// console.log("Cursor is in folded area, ");
shouldShowFrontmatterBanner = true;
break;
}
folded.next();
}
if (!isCursorInRange(state, [parent.from, parent.to])) {
widgets.push(
Decoration.line({
class: "sb-line-frontmatter-outside",
}).range(node.from),
);
shouldShowFrontmatterBanner = true;
}
if (shouldShowFrontmatterBanner && parent.from === node.from) {
// Only put this on the first line of the frontmatter
widgets.push(
Decoration.widget({
widget: new HtmlWidget(
`frontmatter`,
"sb-frontmatter-marker",
),
}).range(node.from),
);
}
}
},
});
return Decoration.set(widgets, true);
}
export function cleanBlockPlugin() {
return decoratorStateField(hideNodes);
return decoratorStateField(
(state: EditorState) => {
const widgets: any[] = [];
syntaxTree(state).iterate({
enter(node) {
if (
node.name === "HorizontalRule" &&
!isCursorInRange(state, [node.from, node.to])
) {
widgets.push(invisibleDecoration.range(node.from, node.to));
widgets.push(
Decoration.line({
class: "sb-line-hr",
}).range(node.from),
);
}
if (
node.name === "Image" &&
!isCursorInRange(state, [node.from, node.to])
) {
widgets.push(invisibleDecoration.range(node.from, node.to));
}
},
});
return Decoration.set(widgets, true);
},
);
}

View File

@ -12,33 +12,35 @@ import { taskListPlugin } from "./task.ts";
import { cleanWikiLinkPlugin } from "./wiki_link.ts";
import { cleanCommandLinkPlugin } from "./command_link.ts";
import { fencedCodePlugin } from "./fenced_code.ts";
import { frontmatterPlugin } from "./frontmatter.ts";
export function cleanModePlugins(editor: Client) {
export function cleanModePlugins(client: Client) {
return [
linkPlugin(editor),
linkPlugin(client),
blockquotePlugin(),
admonitionPlugin(editor),
admonitionPlugin(client),
hideMarksPlugin(),
hideHeaderMarkPlugin(),
cleanBlockPlugin(),
fencedCodePlugin(editor),
frontmatterPlugin(client),
fencedCodePlugin(client),
taskListPlugin({
// TODO: Move this logic elsewhere?
onCheckboxClick: (pos) => {
const clickEvent: ClickEvent = {
page: editor.currentPage!,
page: client.currentPage!,
altKey: false,
ctrlKey: false,
metaKey: false,
pos: pos,
};
// Propagate click event from checkbox
editor.dispatchAppEvent("page:click", clickEvent);
client.dispatchAppEvent("page:click", clickEvent);
},
}),
listBulletPlugin(),
tablePlugin(editor),
cleanWikiLinkPlugin(editor),
cleanCommandLinkPlugin(editor),
tablePlugin(client),
cleanWikiLinkPlugin(client),
cleanCommandLinkPlugin(client),
] as Extension[];
}

View File

@ -71,11 +71,15 @@ export function fencedCodePlugin(editor: Client) {
);
});
const bodyText = lineStrings.slice(1, lineStrings.length - 1).join(
"\n",
);
const widget = renderMode === "markdown"
? new MarkdownWidget(
from + lineStrings[0].length + 1,
editor,
lineStrings.slice(1, lineStrings.length - 1).join("\n"),
`widget:${editor.currentPage}:${bodyText}`,
bodyText,
codeWidgetCallback,
"sb-markdown-widget",
)

View File

@ -0,0 +1,84 @@
import { Client } from "../client.ts";
import { Decoration, EditorState, syntaxTree } from "../deps.ts";
import { MarkdownWidget } from "./markdown_widget.ts";
import { decoratorStateField, HtmlWidget, isCursorInRange } from "./util.ts";
export function frontmatterPlugin(client: Client) {
const panelWidgetHook = client.system.panelWidgetHook;
const frontmatterCallback = panelWidgetHook.callbacks.get("frontmatter");
return decoratorStateField(
(state: EditorState) => {
const widgets: any[] = [];
syntaxTree(state).iterate({
enter(node) {
if (
node.name === "FrontMatter"
) {
if (!isCursorInRange(state, [node.from, node.to])) {
if (frontmatterCallback) {
// Render as a widget
const text = state.sliceDoc(node.from, node.to);
const lineStrings = text.split("\n");
const lines: { from: number; to: number }[] = [];
let fromIt = node.from;
for (const line of lineStrings) {
lines.push({
from: fromIt,
to: fromIt + line.length,
});
fromIt += line.length + 1;
}
lines.slice(0, lines.length - 1).forEach((line) => {
widgets.push(
// Reusing line-table-outside here for laziness reasons
Decoration.line({ class: "sb-line-table-outside" }).range(
line.from,
),
);
});
widgets.push(
Decoration.widget({
widget: new MarkdownWidget(
undefined,
client,
`frontmatter:${client.currentPage}`,
"",
frontmatterCallback,
"sb-markdown-frontmatter-widget",
),
block: true,
}).range(lines[lines.length - 1].from),
);
} else if (!frontmatterCallback) {
// Not rendering as a widget
widgets.push(
Decoration.widget({
widget: new HtmlWidget(
`frontmatter`,
"sb-frontmatter-marker",
),
}).range(node.from),
);
widgets.push(
Decoration.line({
class: "sb-line-frontmatter-outside",
}).range(node.from),
);
widgets.push(
Decoration.line({
class: "sb-line-frontmatter-outside",
}).range(state.doc.lineAt(node.to).from),
);
}
}
}
},
});
return Decoration.set(widgets, true);
},
);
}

View File

@ -5,17 +5,16 @@ import { renderMarkdownToHtml } from "../../plugs/markdown/markdown_render.ts";
import { resolveAttachmentPath } from "$sb/lib/resolve.ts";
import { parse } from "../../common/markdown_parser/parse_tree.ts";
import buildMarkdown from "../../common/markdown_parser/parser.ts";
import { renderToText } from "$sb/lib/tree.ts";
const activeWidgets = new Set<MarkdownWidget>();
export class MarkdownWidget extends WidgetType {
renderedMarkdown?: string;
public dom?: HTMLElement;
constructor(
readonly from: number | undefined,
readonly client: Client,
readonly cacheKey: string,
readonly bodyText: string,
readonly codeWidgetCallback: CodeWidgetCallback,
readonly className: string,
@ -26,11 +25,12 @@ export class MarkdownWidget extends WidgetType {
toDOM(): HTMLElement {
const div = document.createElement("div");
div.className = this.className;
const cacheItem = this.client.getWidgetCache(this.bodyText);
const cacheItem = this.client.getWidgetCache(this.cacheKey);
if (cacheItem) {
div.innerHTML = this.wrapHtml(
cacheItem.html,
cacheItem.buttons,
cacheItem.buttons || [],
cacheItem.banner,
);
this.attachListeners(div, cacheItem.buttons);
}
@ -55,7 +55,7 @@ export class MarkdownWidget extends WidgetType {
if (!widgetContent) {
div.innerHTML = "";
this.client.setWidgetCache(
this.bodyText,
this.cacheKey,
{ height: div.clientHeight, html: "" },
);
return;
@ -73,8 +73,6 @@ export class MarkdownWidget extends WidgetType {
this.client.currentPage,
],
);
// Used for the source button
this.renderedMarkdown = renderToText(mdTree);
const html = renderMarkdownToHtml(mdTree, {
// Annotate every element with its position so we can use it to put
@ -97,27 +95,48 @@ export class MarkdownWidget extends WidgetType {
// HTML still same as in cache, no need to re-render
return;
}
div.innerHTML = this.wrapHtml(html, widgetContent.buttons);
div.innerHTML = this.wrapHtml(
html,
widgetContent.buttons || [],
widgetContent.banner,
);
this.attachListeners(div, widgetContent.buttons);
// Let's give it a tick, then measure and cache
setTimeout(() => {
this.client.setWidgetCache(
this.bodyText,
{ height: div.offsetHeight, html, buttons: widgetContent.buttons },
this.cacheKey,
{
height: div.offsetHeight,
html,
buttons: widgetContent.buttons,
banner: widgetContent.banner,
},
);
// Because of the rejiggering of the DOM, we need to do a no-op cursor move to make sure it's positioned correctly
this.client.editorView.dispatch({
selection: {
anchor: this.client.editorView.state.selection.main.anchor,
},
});
});
}
private wrapHtml(html: string, buttons?: CodeWidgetButton[]) {
if (!buttons) {
return html;
private wrapHtml(
html: string,
buttons: CodeWidgetButton[],
banner?: string,
) {
if (!html) {
return "";
}
return `<div class="button-bar">${
buttons.map((button, idx) =>
buttons.filter((button) => !button.widgetTarget).map((button, idx) =>
`<button data-button="${idx}" title="${button.description}">${button.svg}</button> `
).join("")
}</div>${html}`;
}</div>${
banner ? `<div class="sb-banner">${escapeHtml(banner)}</div>` : ""
}${html}`;
}
private attachListeners(div: HTMLElement, buttons?: CodeWidgetButton[]) {
@ -126,6 +145,7 @@ export class MarkdownWidget extends WidgetType {
// Override default click behavior with a local navigate (faster)
el.addEventListener("click", (e) => {
e.preventDefault();
e.stopPropagation();
const [pageName, pos] = el.dataset.ref!.split(/[$@]/);
if (pos && pos.match(/^\d+$/)) {
this.client.navigate(pageName, +pos);
@ -138,9 +158,18 @@ export class MarkdownWidget extends WidgetType {
// Implement task toggling
div.querySelectorAll("span[data-external-task-ref]").forEach((el: any) => {
const taskRef = el.dataset.externalTaskRef;
el.querySelector("input[type=checkbox]").addEventListener(
const input = el.querySelector("input[type=checkbox]")!;
input.addEventListener(
"click",
(e: any) => {
// Avoid triggering the click on the parent
e.stopPropagation();
},
);
input.addEventListener(
"change",
(e: any) => {
e.stopPropagation();
const oldState = e.target.dataset.state;
const newState = oldState === " " ? "x" : " ";
// Update state in DOM as well for future toggles
@ -162,29 +191,37 @@ export class MarkdownWidget extends WidgetType {
for (let i = 0; i < buttons.length; i++) {
const button = buttons[i];
div.querySelector(`button[data-button="${i}"]`)!.addEventListener(
"click",
() => {
console.log("Button clicked:", button.description);
if (button.widgetTarget) {
div.addEventListener("click", () => {
console.log("Widget clicked");
this.client.system.localSyscall("system.invokeFunction", [
button.invokeFunction,
this.from,
]).then((newContent: string | undefined) => {
if (newContent) {
div.innerText = newContent;
}
this.client.focus();
}).catch(console.error);
},
);
]).catch(console.error);
});
} else {
div.querySelector(`button[data-button="${i}"]`)!.addEventListener(
"click",
(e) => {
e.stopPropagation();
console.log("Button clicked:", button.description);
this.client.system.localSyscall("system.invokeFunction", [
button.invokeFunction,
this.from,
]).then((newContent: string | undefined) => {
if (newContent) {
div.innerText = newContent;
}
this.client.focus();
}).catch(console.error);
},
);
}
}
// div.querySelectorAll("ul > li").forEach((el) => {
// el.classList.add("sb-line-li-1", "sb-line-ul");
// });
}
get estimatedHeight(): number {
const cacheItem = this.client.getWidgetCache(this.bodyText);
const cacheItem = this.client.getWidgetCache(this.cacheKey);
// console.log("Calling estimated height", this.bodyText, cacheItem);
return cacheItem ? cacheItem.height : -1;
}
@ -192,7 +229,7 @@ export class MarkdownWidget extends WidgetType {
eq(other: WidgetType): boolean {
return (
other instanceof MarkdownWidget &&
other.bodyText === this.bodyText
other.bodyText === this.bodyText && other.cacheKey === this.cacheKey
);
}
}
@ -218,3 +255,10 @@ function garbageCollectWidgets() {
}
setInterval(garbageCollectWidgets, 5000);
function escapeHtml(text: string) {
return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(
/>/g,
"&gt;",
);
}

View File

@ -8,6 +8,7 @@ const straightQuoteContexts = [
"FrontMatterCode",
"Attribute",
"CommandLink",
"TemplateDirective",
];
// TODO: Add support for selection (put quotes around or create blockquote block?)

View File

@ -17,6 +17,7 @@ export function postScriptPrefacePlugin(
undefined,
editor,
`top:${editor.currentPage}`,
"",
topCallback,
"sb-markdown-top-widget",
),
@ -33,6 +34,7 @@ export function postScriptPrefacePlugin(
undefined,
editor,
`bottom:${editor.currentPage}`,
"",
bottomCallback,
"sb-markdown-bottom-widget",
),

View File

@ -166,8 +166,9 @@ export function shouldRenderAsCode(
if (mainSelection.empty) {
return checkRangeOverlap(range, [mainSelection.from, mainSelection.to]);
} else {
// If the selection is encompassing the fenced code we render as code
return checkRangeSubset([mainSelection.from, mainSelection.to], range);
// If the selection is encompassing the fenced code we render as code, or vice versa
return checkRangeSubset([mainSelection.from, mainSelection.to], range) ||
checkRangeSubset(range, [mainSelection.from, mainSelection.to]);
}
}

View File

@ -48,7 +48,7 @@ export class PanelWidgetHook implements Hook<PanelWidgetT> {
if (!functionDef.panelWidget) {
continue;
}
if (!["top", "bottom"].includes(functionDef.panelWidget)) {
if (!["top", "bottom", "frontmatter"].includes(functionDef.panelWidget)) {
errors.push(
`Panel widgets must be attached to either 'top' or 'bottom'.`,
);

View File

@ -172,7 +172,8 @@
color: var(--editor-heading-meta-color);
}
.sb-hashtag {
.sb-hashtag,
.sb-markdown-frontmatter-widget span.hashtag {
color: var(--editor-hashtag-color);
background-color: var(--editor-hashtag-background-color);
border: 1px solid var(--editor-hashtag-border-color);
@ -313,6 +314,10 @@
color: var(--editor-frontmatter-color);
}
.sb-markdown-frontmatter-widget {
background-color: var(--editor-frontmatter-background-color);
}
.sb-frontmatter-marker {
color: var(--editor-frontmatter-marker-color);
}

View File

@ -429,7 +429,33 @@
margin-top: 10px;
}
.sb-markdown-frontmatter-widget {
margin-bottom: -1.3ch;
padding: 8px;
.sb-banner {
position: absolute;
right: 5px;
bottom: 5px;
font-size: 80%;
color: var(--editor-frontmatter-marker-color);
}
span.hashtag {
border-radius: 6px;
padding: 0 3px;
margin: 0 1px 0 0;
font-size: 0.9em;
}
}
.sb-markdown-frontmatter-widget+.sb-frontmatter {
background-color: transparent;
color: transparent;
}
.sb-markdown-widget,
.sb-markdown-frontmatter-widget,
.sb-markdown-top-widget:has(*),
.sb-markdown-bottom-widget:has(*) {
overflow-y: auto;
@ -437,6 +463,7 @@
border-radius: 5px;
white-space: normal;
position: relative;
min-height: 48px;
ul,
ol {
@ -504,8 +531,7 @@
background: var(--editor-widget-background-color);
padding-inline: 3px;
padding: 4px 0;
// border-radius: 0 5px;
border-radius: 0 0 0 5px;
button {
border: none;
@ -553,12 +579,6 @@
padding-right: 8px;
}
.sb-frontmatter-marker {
float: right;
font-size: 80%;
padding-right: 7px;
}
.cm-scroller {
// Give some breathing space at the bottom of the screen
padding-bottom: 20em;

View File

@ -98,7 +98,7 @@ html {
--editor-admonition-note-background-color: rgba(0, 184, 212, 0.1);
--editor-admonition-warning-border-color: rgb(255, 145, 0);
--editor-admonition-warning-background-color: rgba(255, 145, 0, 0.1);
--editor-frontmatter-background-color: rgba(255, 246, 189, 0.5);
--editor-frontmatter-background-color: rgba(255, 246, 189, 0.3);
--editor-frontmatter-color: var(--subtle-color);
--editor-frontmatter-marker-color: #89000080;
--editor-widget-background-color: rgb(238, 238, 238);

View File

@ -6,6 +6,8 @@ release.
_Not yet released, this will likely become 0.6.0._
* **Directives have now been removed** from the code base. Please use [[Live Queries]] and [[Live Templates]] instead. If you hadnt migrated yet and want to auto migrate, downgrade your SilverBullet version to 0.5.11 (e.g. using the `zefhemel/silverbullet:0.5.11` docker image) and run the {[Directive: Convert Entire Space to Live/Templates]} command with that version.
* Custom renderer for [[Frontmatter]], enabling... [[Live Frontmatter Templates]] to specify custom rendering (using [[Templates]] of course) — see some of the plugs pages (e.g. [[Plugs/Editor]], [[Plugs/Git]]) to see what you can do with this (template here: [[internal-template/plug-frontmatter]]).
* Somewhat nicer rendering of {{templateVars}}.
---

View File

@ -1,10 +1,14 @@
---
status: Complete
tags: meta
---
Frontmatter is a common format to attach additional metadata (data about data) to markdown documents.
In SilverBullet, there are multiple ways to attach [[Metadata]] to a page; frontmatter is one of them.
You create it by starting your markdown document with `---` followed by [[YAML]] encoded attributes and then ending with `---` again. Followed by the regular body of your document.
You create it by starting your markdown document with `---` followed by [[YAML]] encoded attributes and then ending with `---` again. Followed by the regular body of your document. This very page contains some frontmatter, click on it to see the underlying code.
Here is an example:
Here is another example:
---
status: Draft

View File

@ -0,0 +1,33 @@
Live Frontmatter Templates allow you to override the default rendering of [[Frontmatter]] at the top of your pages with a custom template.
If you have no idea what that means or what you would use this for; you probably dont need this feature. Dont worry about it.
# Defining
Live Frontmatter Templates follow the same pattern as other [[Templates]] with a few additional attributes:
* `tags`: should be set to `template` as for any other template
* `type`: should be set to `frontmatter`
* `where`: should contain an [[Live Queries$expression]] that evaluates to true for the _pages_ you would like to apply this Live Frontmatter Template to, usually this checks for a specific tag, but it can be any expression. Think of this as a `where` clause that should match for the pages this template is applied to.
* `priority` (optional): in case you have multiple Live Frontmatter Templates that have matching `where` expression, the one with the priority set to the lowest number wins.
# Example
The following Frontmatter Template applies to all pages tagged with `person` (see the `where`). It first lists all [[Frontmatter]] attributes, followed by a use of the [[!silverbullet.md/template/live/incoming]] template, showing all incomplete tasks that reference this particular page.
Indeed, you can use [[Live Queries]] and [[Live Templates]] here as well.
---
tags: template
type: frontmatter
where: 'tags = "person"'
---
{{#each .}}**{{@key}}**: {{.}}
{{/each}}
## Incoming tasks
```template
page: "[[!silverbullet.md/template/live/incoming]]"
```
## Plug frontmatter template
This site uses the [[internal-template/plug-frontmatter]] template for pages tagged with `plug`, such as [[Plugs/Editor]], [[Plugs/Github]] and [[Plugs/Mermaid]].

View File

@ -1,4 +1,6 @@
#plug
---
tags: plug
---
The `editor` plug implements foundational editor functionality for SilverBullet.

View File

@ -1,3 +1,4 @@
#plug
---
tags: plug
---
The Emoji plug provides support for auto-completion of the `:emoji:` style syntax. It currently has support for the [15.1 emoji unicode standard](https://emojipedia.org/emoji-15.1). 🎉

View File

@ -2,8 +2,10 @@
uri: github:silverbulletmd/silverbullet-github/github.plug.js
repo: https://github.com/silverbulletmd/silverbullet-github
author: Zef Hemel
shareSupport: true
tags: plug
---
#plug #share-support
```template
page: "[[!raw.githubusercontent.com/silverbulletmd/silverbullet-github/main/README]]"
raw: true

View File

@ -1,5 +1,6 @@
#plug
---
tags: plug
---
SilverBullet has a generic indexing infrastructure for [[Objects]]. Pages are automatically index upon save, so about every second.
The [[Plugs/Index]] plug also defines syntax for [[Tags]].

View File

@ -1,5 +1,7 @@
#share-support #plug
---
tags: plug
shareSupport: true
---
The Markdown plug provides support for various advanced Markdown features, specifically:
* {[Markdown Preview: Toggle]} preview

View File

@ -2,9 +2,9 @@
uri: github:silverbulletmd/silverbullet-mattermost/mattermost.plug.json
repo: https://github.com/silverbulletmd/silverbullet-mattermost
author: Zef Hemel
shareSupport: true
tags: plug
---
#plug #share-support
> **warning** Unmaintained
> This plug is currently not being maintained, it may break at any time

View File

@ -5,7 +5,6 @@ uri: github:silverbulletmd/silverbullet-mermaid/mermaid.plug.js
repo: https://github.com/silverbulletmd/silverbullet-mermaid
author: Zef Hemel
---
Example use:
```mermaid
flowchart TD

View File

@ -2,8 +2,9 @@
uri: github:m1lt0n/silverbullet-serendipity/serendipity.plug.json
repo: https://github.com/m1lt0n/silverbullet-serendipity
author: Pantelis Vratsalis
tags: plug
---
#plug
```template
page: "[[!raw.githubusercontent.com/m1lt0n/silverbullet-serendipity/main/README]]"
raw: true

View File

@ -1,13 +1,12 @@
---
repo: https://github.com/silverbulletmd/silverbullet
tags: plug
---
#plug
The Share plug provides infrastructure for sharing pages outside of your space. It standardizes the {[Share: Publish]} (bound to `Cmd-s` or `Ctrl-s`) to publish the current page to all share providers specified under the `$share` key in [[Frontmatter]].
See the [original RFC](https://github.com/silverbulletmd/silverbullet/discussions/117) for implementation details.
Specific implementations for sharing are implemented in other plugs, specifically:
```query
share-support render [[template/page]]
plug where shareSupport = true render [[template/page]]
```

View File

@ -1,5 +1,6 @@
#plug
---
tags: plug
---
The Tasks plug implements task support in SilverBullet.
## Task states

View File

@ -1,5 +1,6 @@
#plug
---
tags: plug
---
The [[Plugs/Template]] plug implements a few templating mechanisms.
# Daily Note

View File

@ -5,6 +5,7 @@ There are two general uses for templates:
1. _Live_ uses, where page content is dynamically updated based on templates:
* [[Live Queries]]
* [[Live Templates]]
* [[Live Frontmatter Templates]]
2. _One-off_ uses, where a template is instantiated once and inserted into an existing or new page:
* [[Slash Templates]]
* [[Page Templates]]
@ -24,7 +25,7 @@ Tagging a page with a `#template` tag (either in the [[Frontmatter]] or using a
[[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]]
* `type` (optional): should be set to `page` for [[Page Templates]] and to `frontmatter` for [[Live Frontmatter 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.

View File

@ -0,0 +1,7 @@
---
tags: template
type: frontmatter
where: 'tags = "plug"'
---
{{#if author}}This page documents a [[Plugs|plug]] created by **{{author}}**. [Repository]({{repo}}).{{else}}This page documents a [[Plugs|plug]] built into SilverBullet.{{/if}}
{{#if shareSupport}}_This plug supports [[Plugs/Share]]_{{/if}}