1
0

Rebuilt frontmatter templates as template widgets

This commit is contained in:
Zef Hemel 2024-01-08 17:08:26 +01:00
parent 5b3dd500e4
commit 848e11a773
45 changed files with 471 additions and 483 deletions

View File

@ -36,6 +36,7 @@ export const builtinLanguages: Record<string, Language> = {
"template": StreamLanguage.define(yamlLanguage),
"embed": StreamLanguage.define(yamlLanguage),
"data": StreamLanguage.define(yamlLanguage),
"toc": StreamLanguage.define(yamlLanguage),
"javascript": javascriptLanguage,
"js": javascriptLanguage,
"typescript": typescriptLanguage,

View File

@ -66,9 +66,6 @@ export class HttpSpacePrimitives implements SpacePrimitives {
async fetchFileList(): Promise<FileMeta[]> {
const resp = await this.authenticatedFetch(`${this.url}/index.json`, {
method: "GET",
headers: {
Accept: "application/json",
},
});
if (
@ -94,6 +91,10 @@ export class HttpSpacePrimitives implements SpacePrimitives {
`${this.url}/${encodeURI(name)}`,
{
method: "GET",
headers: {
// This header won't trigger CORS preflight requests but can be interpreted on the server
Accept: "application/octet-stream",
},
},
);
if (res.status === 404) {

View File

@ -1,5 +1,5 @@
import { assertEquals } from "../../test_deps.ts";
import { determineTags } from "./cheap_yaml.ts";
import { determineTags, isTemplate } from "./cheap_yaml.ts";
Deno.test("cheap yaml", () => {
assertEquals([], determineTags(""));
@ -14,3 +14,77 @@ Deno.test("cheap yaml", () => {
determineTags(`tags:\n- "#bla"\n- template`),
);
});
Deno.test("Test template extraction", () => {
assertEquals(
isTemplate(`---
name: bla
tags: template
---
Sup`),
true,
);
assertEquals(
isTemplate(`---
tags: template, something else
---
`),
true,
);
assertEquals(
isTemplate(`---
tags: something else, template
---
`),
true,
);
assertEquals(
isTemplate(`---
tags:
- bla
- template
---
`),
true,
);
assertEquals(
isTemplate(`#template`),
true,
);
assertEquals(
isTemplate(` #template This is a template`),
true,
);
assertEquals(
isTemplate(`---
tags:
- bla
somethingElse:
- template
---
`),
false,
);
assertEquals(
isTemplate(`---
name: bla
tags: aefe
---
Sup`),
false,
);
assertEquals(
isTemplate(`Sup`),
false,
);
});

View File

@ -34,3 +34,28 @@ export function determineTags(yamlText: string): string[] {
}
return tags;
}
const frontMatterRegex = /^---\n(([^\n]|\n)*?)---\n/;
/**
* Quick and dirty way to check if a page is a template or not
* @param pageText
* @returns
*/
export function isTemplate(pageText: string): boolean {
const frontmatter = frontMatterRegex.exec(pageText);
// Poor man's YAML frontmatter parsing
if (frontmatter) {
pageText = pageText.slice(frontmatter[0].length);
const frontmatterText = frontmatter[1];
const tags = determineTags(frontmatterText);
if (tags.includes("template")) {
return true;
}
}
// Or if the page text starts with a #template tag
if (/^\s*#template(\W|$)/.test(pageText)) {
return true;
}
return false;
}

View File

@ -5,7 +5,7 @@ export function render(
lang: string,
body: string,
pageName: string,
): Promise<CodeWidgetContent> {
): Promise<CodeWidgetContent | null> {
return syscall("codeWidget.render", lang, body, pageName);
}

View File

@ -127,14 +127,13 @@ export type ObjectQuery = Omit<Query, "prefix">;
export type CodeWidgetCallback = (
bodyText: string,
pageName: string,
) => Promise<CodeWidgetContent>;
) => Promise<CodeWidgetContent | null>;
export type CodeWidgetContent = {
html?: string;
markdown?: string;
script?: string;
buttons?: CodeWidgetButton[];
banner?: string;
};
export type CodeWidgetButton = {

View File

@ -120,7 +120,12 @@ export async function readFile(
): Promise<{ data: Uint8Array; meta: FileMeta } | undefined> {
const url = federatedPathToUrl(name);
console.log("Fetching federated file", url);
const r = await nativeFetch(url);
const r = await nativeFetch(url, {
method: "GET",
headers: {
Accept: "application/octet-stream",
},
});
if (r.status === 503) {
throw new Error("Offline");
}

View File

@ -76,7 +76,8 @@ export const builtins: Record<string, Record<string, string>> = {
pos: "!number",
type: "string",
trigger: "string",
forTags: "string[]",
where: "string",
priority: "number",
},
};

View File

@ -2,7 +2,7 @@ import { editor, events, markdown, mq, space, system } from "$sb/syscalls.ts";
import { sleep } from "$sb/lib/async.ts";
import { IndexEvent } from "$sb/app_event.ts";
import { MQMessage } from "$sb/types.ts";
import { isTemplate } from "../template/util.ts";
import { isTemplate } from "$sb/lib/cheap_yaml.ts";
export async function reindexCommand() {
await editor.flashNotification("Performing full page reindex...");
@ -64,6 +64,7 @@ export async function parseIndexTextRepublish({ name, text }: IndexEvent) {
tree: parsed,
});
} else {
console.log("Indexing", name, "as page");
await events.dispatchEvent("page:index", {
name,
tree: parsed,

View File

@ -155,44 +155,27 @@ functions:
command:
name: "Page: Extract"
# Mentions panel (postscript)
toggleMentions:
path: "./linked_mentions.ts:toggleMentions"
command:
name: "Mentions: Toggle"
key: ctrl-alt-m
priority: 5
renderMentions:
path: "./linked_mentions.ts:renderMentions"
panelWidget: bottom
# TOC
toggleTOC:
path: toc.ts:toggleTOC
command:
name: "Table of Contents: Toggle"
key: ctrl-alt-t
priority: 5
tocWidget:
path: toc.ts:widget
codeWidget: toc
renderMode: markdown
renderTOC:
path: toc.ts:renderTOC
# Template Widgets
renderTemplateWidgetsTop:
path: template_widget.ts:renderTemplateWidgets
env: client
panelWidget: top
renderTemplateWidgetsBottom:
path: template_widget.ts:renderTemplateWidgets
env: client
panelWidget: bottom
refreshWidgets:
path: toc.ts:refreshWidgets
path: template_widget.ts:refreshWidgets
lintYAML:
path: lint.ts:lintYAML
events:
- editor:lint
renderFrontmatterWidget:
path: frontmatter.ts:renderFrontmatterWidget
env: client
panelWidget: frontmatter
editFrontmatter:
path: frontmatter.ts:editFrontmatter

View File

@ -1,61 +0,0 @@
import { clientStore, codeWidget, editor, system } from "$sb/syscalls.ts";
import { CodeWidgetContent } from "$sb/types.ts";
import { queryObjects } from "./api.ts";
import { LinkObject } from "./page_links.ts";
const hideMentionsKey = "hideMentions";
export async function toggleMentions() {
let hideMentions = await clientStore.get(hideMentionsKey);
hideMentions = !hideMentions;
await clientStore.set(hideMentionsKey, hideMentions);
await codeWidget.refreshAll();
}
export async function renderMentions(): Promise<CodeWidgetContent | null> {
if (await clientStore.get(hideMentionsKey)) {
return null;
}
const page = await editor.getCurrentPage();
const linksResult = await queryObjects<LinkObject>("link", {
// Query all links that point to this page
filter: ["and", ["!=", ["attr", "page"], ["string", page]], ["=", [
"attr",
"toPage",
], ["string", page]]],
});
if (linksResult.length === 0) {
// Don't show the panel if there are no links here.
return null;
} else {
let renderedMd = "# Linked Mentions\n";
for (const link of linksResult) {
let snippet = await system.invokeFunction(
"markdown.markdownToHtml",
link.snippet,
);
// strip HTML tags
snippet = snippet.replace(/<[^>]*>?/gm, "");
renderedMd += `* [[${link.ref}]]: ...${snippet}...\n`;
}
return {
markdown: renderedMd,
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: "Hide",
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-eye-off"><path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24"></path><line x1="1" y1="1" x2="23" y2="23"></line></svg>`,
invokeFunction: "index.toggleMentions",
},
],
};
}
}

View File

@ -1,22 +1,26 @@
import {
codeWidget,
editor,
language,
markdown,
space,
} from "$sb/silverbullet-syscall/mod.ts";
import { parseTreeToAST, renderToText } from "$sb/lib/tree.ts";
import { CodeWidgetContent } from "$sb/types.ts";
import { editor, language, markdown, space } from "$sb/syscalls.ts";
import { extractFrontmatter } from "$sb/lib/frontmatter.ts";
import { loadPageObject } from "../template/template.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";
import { renderTemplate } from "../template/plug_api.ts";
import { extractFrontmatter } from "$sb/lib/frontmatter.ts";
import { rewritePageRefs } from "$sb/lib/resolve.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 refreshWidgets() {
await codeWidget.refreshAll();
}
export async function renderFrontmatterWidget(): Promise<
export async function renderTemplateWidgets(side: "top" | "bottom"): Promise<
CodeWidgetContent | null
> {
const text = await editor.getText();
@ -27,11 +31,11 @@ export async function renderFrontmatterWidget(): Promise<
const allFrontMatterTemplates = await queryObjects<TemplateObject>(
"template",
{
filter: ["=", ["attr", "type"], ["string", "frontmatter"]],
filter: ["=", ["attr", "type"], ["string", `widget:${side}`]],
orderBy: [{ expr: ["attr", "priority"], desc: false }],
},
);
let templateText = fallbackTemplate;
const templateBits: string[] = [];
// Strategy: walk through all matching templates, evaluate the 'where' expression, and pick the first one that matches
for (const template of allFrontMatterTemplates) {
const exprAST = parseTreeToAST(
@ -40,19 +44,25 @@ export async function renderFrontmatterWidget(): Promise<
const parsedExpression = expressionToKvQueryExpression(exprAST[1]);
if (evalQueryExpression(parsedExpression, pageMeta)) {
// Match! We're happy
templateText = await space.readPage(template.ref);
break;
const templateText = await space.readPage(template.ref);
// templateBits.push(await space.readPage(template.ref));
let renderedTemplate = (await renderTemplate(
templateText,
pageMeta,
frontmatter,
)).text;
const parsedMarkdown = await markdown.parseMarkdown(renderedTemplate);
rewritePageRefs(parsedMarkdown, template.ref);
renderedTemplate = renderToText(parsedMarkdown);
templateBits.push(renderedTemplate);
}
}
const summaryText = await renderTemplate(
templateText,
pageMeta,
frontmatter,
);
const summaryText = templateBits.join("");
// console.log("Rendered", summaryText);
return {
markdown: summaryText.text,
banner: "frontmatter",
markdown: summaryText,
buttons: [
{
description: "Reload",
@ -60,23 +70,6 @@ export async function renderFrontmatterWidget(): Promise<
`<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

@ -1,14 +1,8 @@
import {
clientStore,
codeWidget,
editor,
markdown,
} from "$sb/silverbullet-syscall/mod.ts";
import { renderToText, traverseTree } from "$sb/lib/tree.ts";
import { editor, markdown, YAML } from "$sb/syscalls.ts";
import { CodeWidgetContent } from "$sb/types.ts";
import { renderToText, traverseTree } from "$sb/lib/tree.ts";
const hideTOCKey = "hideTOC";
const headerThreshold = 3;
const defaultHeaderThreshold = 0;
type Header = {
name: string;
@ -16,21 +10,19 @@ type Header = {
level: number;
};
export async function toggleTOC() {
let hideTOC = await clientStore.get(hideTOCKey);
hideTOC = !hideTOC;
await clientStore.set(hideTOCKey, hideTOC);
await codeWidget.refreshAll();
}
type TocConfig = {
minHeaders?: number;
header?: boolean;
};
export async function refreshWidgets() {
await codeWidget.refreshAll();
}
export async function renderTOC(): Promise<CodeWidgetContent | null> {
if (await clientStore.get(hideTOCKey)) {
return null;
export async function widget(
bodyText: string,
): Promise<CodeWidgetContent | null> {
let config: TocConfig = {};
if (bodyText.trim() !== "") {
config = await YAML.parse(bodyText);
}
const page = await editor.getCurrentPage();
const text = await editor.getText();
const tree = await markdown.parseMarkdown(text);
@ -47,17 +39,26 @@ export async function renderTOC(): Promise<CodeWidgetContent | null> {
}
return false;
});
let headerThreshold = defaultHeaderThreshold;
if (config.minHeaders) {
headerThreshold = config.minHeaders;
}
if (headers.length < headerThreshold) {
// Not enough headers, not showing TOC
return null;
}
let headerText = "# Table of Contents\n";
if (config.header === false) {
headerText = "";
}
// console.log("Headers", headers);
// Adjust level down if only sub-headers are used
const minLevel = headers.reduce(
(min, header) => Math.min(min, header.level),
6,
);
const renderedMd = "# Table of Contents\n" +
const renderedMd = headerText +
headers.map((header) =>
`${
" ".repeat((header.level - minLevel) * 2)
@ -67,18 +68,18 @@ export async function renderTOC(): Promise<CodeWidgetContent | null> {
return {
markdown: renderedMd,
buttons: [
{
description: "Edit",
svg:
`<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-edit"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"></path></svg>`,
invokeFunction: "query.editButton",
},
{
description: "Reload",
svg:
`<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-refresh-cw"><polyline points="23 4 23 10 17 10"></polyline><polyline points="1 20 1 14 7 14"></polyline><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"></path></svg>`,
invokeFunction: "index.refreshWidgets",
},
{
description: "Hide",
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-eye-off"><path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24"></path><line x1="1" y1="1" x2="23" y2="23"></line></svg>`,
invokeFunction: "index.toggleTOC",
},
],
};
}

View File

@ -36,8 +36,13 @@ export async function expandCodeWidgets(
renderToText(codeTextNode!),
pageName,
);
if (!result) {
return {
text: "",
};
}
// Only do this for "markdown" widgets, that is: that can render to markdown
if (result.markdown) {
if (result.markdown !== undefined) {
const parsedBody = await parseMarkdown(result.markdown);
// Recursively process
return expandCodeWidgets(

View File

@ -29,7 +29,3 @@ functions:
path: "./preview.ts:previewClickHandler"
events:
- preview:click
markdownWidget:
path: ./widget.ts:markdownWidget
codeWidget: markdown

View File

@ -1,20 +0,0 @@
import { markdown } from "$sb/syscalls.ts";
import type { WidgetContent } from "$sb/app_event.ts";
import { renderMarkdownToHtml } from "./markdown_render.ts";
export async function markdownWidget(
bodyText: string,
): Promise<WidgetContent> {
const mdTree = await markdown.parseMarkdown(bodyText);
const html = renderMarkdownToHtml(mdTree, {
smartHardBreak: true,
});
return Promise.resolve({
html: html,
script: `
document.addEventListener("click", () => {
api({type: "blur"});
});`,
});
}

View File

@ -4,7 +4,12 @@ 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";
import {
CodeWidgetContent,
LintDiagnostic,
PageMeta,
Query,
} from "$sb/types.ts";
import { jsonToMDTable, renderQueryTemplate } from "../template/util.ts";
export async function widget(
@ -12,53 +17,33 @@ export async function widget(
pageName: string,
): Promise<CodeWidgetContent> {
const pageObject = await loadPageObject(pageName);
try {
let resultMarkdown = "";
const parsedQuery = await parseQuery(
await replaceTemplateVars(bodyText, pageObject),
);
if (!parsedQuery.limit) {
parsedQuery.limit = ["number", 1000];
}
const eventName = `query:${parsedQuery.querySource}`;
let resultMarkdown = "";
// console.log("Parsed query", parsedQuery);
// Let's dispatch an event and see what happens
const results = await events.dispatchEvent(
eventName,
{ query: parsedQuery, pageName: pageObject.name },
30 * 1000,
const results = await performQuery(
parsedQuery,
pageObject,
);
if (results.length === 0) {
// This means there was no handler for the event which means it's unsupported
return {
html:
`**Error:** Unsupported query source '${parsedQuery.querySource}'`,
};
if (results.length === 0 && !parsedQuery.renderAll) {
resultMarkdown = "No results";
} else {
const allResults = results.flat();
if (allResults.length === 0) {
resultMarkdown = "No results";
if (parsedQuery.render) {
// Configured a custom rendering template, let's use it!
const templatePage = resolvePath(pageName, parsedQuery.render);
const rendered = await renderQueryTemplate(
pageObject,
templatePage,
results,
parsedQuery.renderAll!,
);
resultMarkdown = rendered.trim();
} else {
if (parsedQuery.render) {
// Configured a custom rendering template, let's use it!
const templatePage = resolvePath(pageName, parsedQuery.render);
const rendered = await renderQueryTemplate(
pageObject,
templatePage,
allResults,
parsedQuery.renderAll!,
);
resultMarkdown = rendered.trim();
} else {
// TODO: At this point it's a bit pointless to first render a markdown table, and then convert that to HTML
// We should just render the HTML table directly
resultMarkdown = jsonToMDTable(allResults);
}
// TODO: At this point it's a bit pointless to first render a markdown table, and then convert that to HTML
// We should just render the HTML table directly
resultMarkdown = jsonToMDTable(results);
}
}
@ -84,6 +69,25 @@ export async function widget(
}
}
export async function performQuery(parsedQuery: Query, pageObject: PageMeta) {
if (!parsedQuery.limit) {
parsedQuery.limit = ["number", 1000];
}
const eventName = `query:${parsedQuery.querySource}`;
// console.log("Parsed query", parsedQuery);
// Let's dispatch an event and see what happens
const results = await events.dispatchEvent(
eventName,
{ query: parsedQuery, pageName: pageObject.name },
30 * 1000,
);
if (results.length === 0) {
throw new Error(`Unsupported query source '${parsedQuery.querySource}'`);
}
return results.flat();
}
export async function lintQuery(
{ name, tree }: LintEvent,
): Promise<LintDiagnostic[]> {

View File

@ -4,14 +4,20 @@ import { CodeWidgetContent, PageMeta } from "$sb/types.ts";
import { renderTemplate } from "../template/plug_api.ts";
import { renderToText } from "$sb/lib/tree.ts";
import { rewritePageRefs, rewritePageRefsInString } from "$sb/lib/resolve.ts";
import { performQuery } from "./query.ts";
import { parseQuery } from "$sb/lib/parse-query.ts";
type TemplateConfig = {
// Pull the template from a page
page?: string;
// Or use a string directly
template?: string;
// Optional argument to pass
// To feed data into the template you can either use a concrete value
value?: any;
// Or a query
query?: string;
// If true, don't render the template, just use it as-is
raw?: boolean;
};
@ -38,11 +44,20 @@ export async function widget(
templateText = await space.readPage(templatePage);
}
const value = config.value
? JSON.parse(
let value: any;
if (config.value) {
value = JSON.parse(
await replaceTemplateVars(JSON.stringify(config.value), pageMeta),
)
: undefined;
);
}
if (config.query) {
const parsedQuery = await parseQuery(
await replaceTemplateVars(config.query, pageMeta),
);
value = await performQuery(parsedQuery, pageMeta);
}
let { text: rendered } = config.raw
? { text: templateText }

View File

@ -1,76 +0,0 @@
import { assertEquals } from "../../test_deps.ts";
import { isTemplate } from "./util.ts";
Deno.test("Test template extraction", () => {
assertEquals(
isTemplate(`---
name: bla
tags: template
---
Sup`),
true,
);
assertEquals(
isTemplate(`---
tags: template, something else
---
`),
true,
);
assertEquals(
isTemplate(`---
tags: something else, template
---
`),
true,
);
assertEquals(
isTemplate(`---
tags:
- bla
- template
---
`),
true,
);
assertEquals(
isTemplate(`#template`),
true,
);
assertEquals(
isTemplate(` #template This is a template`),
true,
);
assertEquals(
isTemplate(`---
tags:
- bla
somethingElse:
- template
---
`),
false,
);
assertEquals(
isTemplate(`---
name: bla
tags: aefe
---
Sup`),
false,
);
assertEquals(
isTemplate(`Sup`),
false,
);
});

View File

@ -1,34 +1,8 @@
import { determineTags } from "$sb/lib/cheap_yaml.ts";
import { handlebarHelpers } from "../../common/syscalls/handlebar_helpers.ts";
import { PageMeta } from "$sb/types.ts";
import { handlebars, space } from "$sb/syscalls.ts";
import { cleanTemplate } from "./plug_api.ts";
const frontMatterRegex = /^---\n(([^\n]|\n)*?)---\n/;
/**
* Quick and dirty way to check if a page is a template or not
* @param pageText
* @returns
*/
export function isTemplate(pageText: string): boolean {
const frontmatter = frontMatterRegex.exec(pageText);
// Poor man's YAML frontmatter parsing
if (frontmatter) {
pageText = pageText.slice(frontmatter[0].length);
const frontmatterText = frontmatter[1];
const tags = determineTags(frontmatterText);
if (tags.includes("template")) {
return true;
}
}
// Or if the page text starts with a #template tag
if (/^\s*#template(\W|$)/.test(pageText)) {
return true;
}
return false;
}
export function buildHandebarOptions(pageMeta: PageMeta) {
return {
helpers: handlebarHelpers(),

View File

@ -460,17 +460,18 @@ export class HttpServer {
name,
);
if (
name.endsWith(".md") && !request.headers.has("X-Sync-Mode") &&
name.endsWith(".md") &&
// This header signififies the requests comes directly from the http_space_primitives client (not the browser)
!request.headers.has("X-Sync-Mode") &&
// This Accept header is used by federation to still work with CORS
request.headers.get("Accept") !==
"application/octet-stream" &&
request.headers.get("sec-fetch-mode") !== "cors"
) {
// It can happen that during a sync, authentication expires, this may result in a redirect to the login page and then back to this particular file. This particular file may be an .md file, which isn't great to show so we're redirecting to the associated SB UI page.
console.warn(
"Request was without X-Sync-Mode nor a CORS request, redirecting to page",
);
// Log all request headers
// for (const [key, value] of request.headers.entries()) {
// console.log("Header", key, value);
// }
response.redirect(`/${name.slice(0, -3)}`);
return;
}

View File

@ -315,7 +315,10 @@ export class Client {
setTimeout(() => {
this.editorView.dispatch({
selection: { anchor: pos as number },
effects: EditorView.scrollIntoView(pos as number, { y: "start" }),
effects: EditorView.scrollIntoView(pos as number, {
y: "start",
yMargin: 5,
}),
});
});
} else if (!stateRestored) {

View File

@ -8,6 +8,7 @@ import {
} from "./util.ts";
import { MarkdownWidget } from "./markdown_widget.ts";
import { IFrameWidget } from "./iframe_widget.ts";
import { isTemplate } from "$sb/lib/cheap_yaml.ts";
export function fencedCodePlugin(editor: Client) {
return decoratorStateField((state: EditorState) => {
@ -27,7 +28,8 @@ export function fencedCodePlugin(editor: Client) {
const renderMode = editor.system.codeWidgetHook.codeWidgetModes.get(
lang,
);
if (codeWidgetCallback) {
// Only custom render when we have a custom renderer, and the current page is not a template
if (codeWidgetCallback && !isTemplate(state.sliceDoc(0, from))) {
// We got a custom renderer!
const lineStrings = text.split("\n");

View File

@ -35,14 +35,23 @@ export class IFrameWidget extends WidgetType {
case "reload":
this.codeWidgetCallback(this.bodyText, this.client.currentPage!)
.then(
(widgetContent: WidgetContent) => {
iframe.contentWindow!.postMessage({
type: "html",
html: widgetContent.html,
script: widgetContent.script,
theme:
document.getElementsByTagName("html")[0].dataset.theme,
});
(widgetContent: WidgetContent | null) => {
if (widgetContent === null) {
iframe.contentWindow!.postMessage({
type: "html",
html: "",
theme:
document.getElementsByTagName("html")[0].dataset.theme,
});
} else {
iframe.contentWindow!.postMessage({
type: "html",
html: widgetContent.html,
script: widgetContent.script,
theme:
document.getElementsByTagName("html")[0].dataset.theme,
});
}
},
);
break;

View File

@ -27,12 +27,10 @@ export class MarkdownWidget extends WidgetType {
div.className = this.className;
const cacheItem = this.client.getWidgetCache(this.cacheKey);
if (cacheItem) {
div.innerHTML = this.wrapHtml(
cacheItem.html,
cacheItem.buttons || [],
cacheItem.banner,
);
this.attachListeners(div, cacheItem.buttons);
div.innerHTML = this.wrapHtml(cacheItem.html, cacheItem.buttons);
if (cacheItem.html) {
this.attachListeners(div, cacheItem.buttons);
}
}
// Async kick-off of content renderer
@ -90,6 +88,7 @@ export class MarkdownWidget extends WidgetType {
},
preserveAttributes: true,
});
// console.log("Got html", html);
if (cachedHtml === html) {
// HTML still same as in cache, no need to re-render
@ -97,10 +96,11 @@ export class MarkdownWidget extends WidgetType {
}
div.innerHTML = this.wrapHtml(
html,
widgetContent.buttons || [],
widgetContent.banner,
widgetContent.buttons,
);
this.attachListeners(div, widgetContent.buttons);
if (html) {
this.attachListeners(div, widgetContent.buttons);
}
// Let's give it a tick, then measure and cache
setTimeout(() => {
@ -110,7 +110,6 @@ export class MarkdownWidget extends WidgetType {
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
@ -124,8 +123,7 @@ export class MarkdownWidget extends WidgetType {
private wrapHtml(
html: string,
buttons: CodeWidgetButton[],
banner?: string,
buttons: CodeWidgetButton[] = [],
) {
if (!html) {
return "";
@ -134,9 +132,7 @@ export class MarkdownWidget extends WidgetType {
buttons.filter((button) => !button.widgetTarget).map((button, idx) =>
`<button data-button="${idx}" title="${button.description}">${button.svg}</button> `
).join("")
}</div>${
banner ? `<div class="sb-banner">${escapeHtml(banner)}</div>` : ""
}${html}`;
}</div>${html}`;
}
private attachListeners(div: HTMLElement, buttons?: CodeWidgetButton[]) {
@ -255,10 +251,3 @@ function garbageCollectWidgets() {
}
setInterval(garbageCollectWidgets, 5000);
function escapeHtml(text: string) {
return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(
/>/g,
"&gt;",
);
}

View File

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

View File

@ -104,7 +104,7 @@ export function mountIFrame(
preloadedIFrame: PreloadedIFrame,
client: Client,
widgetHeightCacheKey: string | null,
content: WidgetContent | Promise<WidgetContent>,
content: WidgetContent | null | Promise<WidgetContent | null>,
onMessage?: (message: any) => void,
) {
const iframe = preloadedIFrame.iframe;
@ -174,26 +174,28 @@ export function mountIFrame(
console.warn("Iframe went away or content was not loaded");
return;
}
if (resolvedContent.html) {
iframe.contentWindow!.postMessage({
type: "html",
html: resolvedContent.html,
script: resolvedContent.script,
theme: document.getElementsByTagName("html")[0].dataset.theme,
});
} else if (resolvedContent.url) {
iframe.contentWindow!.location.href = resolvedContent.url;
if (resolvedContent.height) {
iframe.height = resolvedContent.height + "px";
if (widgetHeightCacheKey) {
client.setCachedWidgetHeight(
widgetHeightCacheKey!,
resolvedContent.height,
);
if (resolvedContent) {
if (resolvedContent.html) {
iframe.contentWindow!.postMessage({
type: "html",
html: resolvedContent.html,
script: resolvedContent.script,
theme: document.getElementsByTagName("html")[0].dataset.theme,
});
} else if (resolvedContent.url) {
iframe.contentWindow!.location.href = resolvedContent.url;
if (resolvedContent.height) {
iframe.height = resolvedContent.height + "px";
if (widgetHeightCacheKey) {
client.setCachedWidgetHeight(
widgetHeightCacheKey!,
resolvedContent.height,
);
}
}
if (resolvedContent.width) {
iframe.width = resolvedContent.width + "px";
}
}
if (resolvedContent.width) {
iframe.width = resolvedContent.width + "px";
}
}
}).catch(console.error);
@ -202,7 +204,7 @@ export function mountIFrame(
export function createWidgetSandboxIFrame(
client: Client,
widgetHeightCacheKey: string | null,
content: WidgetContent | Promise<WidgetContent>,
content: WidgetContent | null | Promise<WidgetContent | null>,
onMessage?: (message: any) => void,
) {
// console.log("Claiming iframe");

View File

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

View File

@ -429,8 +429,6 @@
margin-top: 10px;
}
.sb-markdown-widget,
.sb-markdown-top-widget:has(*),
.sb-markdown-bottom-widget:has(*) {

View File

@ -11,7 +11,7 @@ export function codeWidgetSyscalls(
lang: string,
body: string,
pageName: string,
): Promise<CodeWidgetContent> => {
): Promise<CodeWidgetContent | null> => {
const langCallback = codeWidgetHook.codeWidgetCallbacks.get(
lang,
);

View File

@ -6,8 +6,14 @@ 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}}.
* New [[Markdown/Code Widgets|Code Widget]]: `toc` to manually include a [[Table of Contents]]
* New template type: [[Live Template Widgets]] allowing you to automatically add templates to the top or bottom of your pages (based on some criteria). Using this feature it possible to implement [[Table of Contents]] and [[Linked Mentions]] without having “hard coded” into SilverBullet itself.
* **“Breaking” change:** Two features are now no longer hardcoded into SilverBullet, but can be activated quite easily using [[Live Template Widgets]] (see their respective documentation pages on instructions on how to do this):
* [[Table of Contents]]
* [[Linked Mentions]]
* Templates:
* Somewhat nicer rendering of {{templateVars}} (notice the gray background)
* Rendering of [[Markdown/Code Widgets]] (such as live queries and templates) **are now disabled** on template pages, which should make them less confusing to read and interpret.
---
@ -129,6 +135,6 @@ Other notable changes:
* [[Plugs/Tasks]] now support custom states (not just `[x]` and `[ ]`), for example:
* [IN PROGRESS] An in progress task
* [BLOCKED] A task thats blocked
[[🔌 Tasks|Read more]]
[[Plugs/Tasks|Read more]]
* Removed [[Cloud Links]] support in favor of [[Federation]]. If you still have legacy cloud links, simply replace the 🌩️ with a `!` and things should work as before.

View File

@ -1 +1,3 @@
Linked mentions
Linked Mentions are references from other pages to the current page. Technically, theyre not a built-in feature, but you can easily implement them using [[Live Template Widgets]].
To enable linked mentions being added to your pages, include the [[template/widget/linked-mentions]] template in your space, either through copy and pasting or through [[Federation]].

View File

@ -1,36 +0,0 @@
Live Frontmatter Templates allow you to override the default rendering of [[Frontmatter]] at the top of your pages with a custom template.
> **warning** Warning
> This feature is still _experimental_, aspects of it may change, or it could be removed altogether.
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

@ -0,0 +1,34 @@
Live Template Widgets allow you to automatically render templated markdown widgets to the top or bottom of pages matching specific criteria.
> **warning** Warning
> This feature is still _experimental_, aspects of it may change, or it could be removed altogether.
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 Template Widgets 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 `widget:top` or `widget:bottom` depending on where you would like it to appear
* `where`: should contain an [[Live Queries$expression]] that evaluates to true for the _pages_ you would like to apply this 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 templates that have matching `where` expression, the one with the priority set to the lowest number wins.
# Example
The following widget template applies to all pages tagged with `person` (see the `where`). It uses the [[!silverbullet.md/template/live/incoming]] template, to show 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"'
---
## Incoming tasks
```template
page: "[[!silverbullet.md/template/live/incoming]]"
```
## Plug widget template
This site uses the [[internal-template/plug-widget]] template for pages tagged with `plug`, such as [[Plugs/Editor]], [[Plugs/Github]] and [[Plugs/Mermaid]].

View File

@ -1,4 +1,4 @@
Live templates rendering [[Templates]] inline in a page.
Live templates render [[Templates]] inline in a page. Theyre called “Live” because their content updates dynamically.
## 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.
@ -16,7 +16,7 @@ template: |
Today is {{today}}!
```
To pass in a value to the template, you can specify the optional `value` attribute:
To pass a literal value to the template, you can specify the optional `value` attribute:
```template
template: |
Hello, {{name}}! Today is _{{today}}_
@ -24,6 +24,17 @@ value:
name: Pete
```
You can also pass in the result of a [[Live Queries|query]] as a value by setting the `query` attribute:
```template
template: |
{{#each .}}
* #{{name}}
{{/each}}
query: |
tag where parent = "page" select name
```
If you just want to render the raw markdown without handling it as a handlebars template, set `raw` to true:
```template
template: |

View File

@ -1,28 +1,21 @@
Code widgets are a SilverBullet-specific “extension” to [[Markdown]]. Technically, its not an extension — it just gives new meaning to markdowns native fenced code blocks — code blocks that start with a triple backtick, specifying a programming language.
Code widgets are a SilverBullet-specific [[Markdown/Extensions|extension]] to [[Markdown]]. Technically, its not an extension — it just gives new meaning to markdowns native fenced code blocks — code blocks that start with a triple backtick, specifying a programming language.
Currently, SilverBullet provides two code widgets as part of its built-in [[Plugs]]:
Currently, SilverBullet provides a few code widgets out of the box:
* `toc`: [[Table of Contents]]
* `query`: [[Live Queries]]
* `template`: [[Live Templates]]
* `embed`
* `markdown`
In addition, plugs like [[Plugs/KaTeX]] and [[Plugs/Mermaid]] add additional ones.
## Embed
This allows you to embed internet content into your page inside of an iframe. This is useful to, for instance, embed youtube videos. In fact, there is specific support for those.
Two examples.
First, embedding the silverbullet.md website inside the silverbullet.md website (inception!):
```embed
url: https://silverbullet.md
height: 500
```
## `embed`
This allows you to embed internet content into your page inside of an iframe. This is useful to embed youtube videos or other websites.
and a YouTube video:
```embed
url: https://www.youtube.com/watch?v=VemS-cqAD5k
url: https://youtu.be/BbNbZgOwB-Y
```
Note, there is specific support for YouTube videos — it automatically sets the width and height, and replaces the URL with an embed URL.
@ -32,10 +25,3 @@ The body of an `embed` block is written in [[YAML]] and supports the following a
* `url` (mandatory): the URL of the content to embed
* `height` (optional): the height of the embedded page in pixels
* `width` (optional): the width of the embedded page in pixels
## Markdown
You can embed markdown inside of markdown and live preview it. Is this useful? 🤷 Not particularly, its more of a demo of how this works. Nevertheless, to each their own, heres an example:
```markdown
This is going to be **bold**
```

View File

@ -6,6 +6,7 @@ In addition to supporting [[Markdown/Basics|markdown basics]] as standardized by
* Generically via [[Markdown/Code Widgets]]
* [[Live Queries]]
* [[Live Templates]]
* [[Table of Contents]]
* [[Anchors]]
* [[Markdown/Admonitions]]
* Hashtags, e.g. `#mytag`.

View File

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

View File

@ -4,16 +4,12 @@ tags: plug
The [[Plugs/Template]] plug implements a few templating mechanisms.
# 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).
# Weekly Note
The {[Open Weekly Note]} command navigates (or creates) a weekly note prefixed
with a 🗓️ emoji by default, but this is configurable via the `weeklyNotePrefix` setting in `SETTINGS`. If you have a page template (see above) named `template/page/Weekly Note` it will use this as a template, otherwise, the page will just be empty.
The {[Open Weekly Note]} command navigates (or creates) a weekly note prefixed with a 🗓️ emoji by default, but this is configurable via the `weeklyNotePrefix` setting in `SETTINGS`. If you have a page template (see above) named `template/page/Weekly Note` it will use this as a template, otherwise, the page will just be empty.
# Quick Note
The {[Quick Note]} command will navigate to an empty page named with the current date and time prefixed with a 📥 emoji, but this is configurable via the `quickNotePrefix` in `SETTINGS`. The use case is to take a quick note outside of your current context.
# Built-in slash commands

View File

@ -1,3 +1,30 @@
The Table of Contents widget, when enabled, shows a table of contents at the start of the page for any page with 3 headers or more. It is updated whenever hovering the mouse cursor over it. Clicking any of the headers will navigate there within the page.
You can add a table of contents to a page using the `toc` [[Markdown/Code Widgets|Code Widget]].
You can enable/disable this feature via {[Table of Contents: Toggle]}.
In its most basic form it looks like this (click the edit button to see the code):
```toc
```
You can use it in two ways:
1. _Manually_, by adding a `toc` widget to the pages where youd like to render a ToC
2. _Automatically_, using a [[Live Template Widgets|Live Template Widget]]
To have a ToC added to all pages with a larger (e.g. 3) number of headings, it is recommended to use [[template/widget/toc|this template widget]]. You can do this by either copy and pasting it into your own space, or by using [[Federation]] and have it included in your space that way:
```yaml
federation:
- uri: silverbullet.md/template/widget/toc
```
## Configuration
In the body of the `toc` code widget you can configure a few options:
* `header`: by default a “Table of Contents” header is added to the ToC, set this to `false` to disable rendering this header
* `minHeaders`: only renders a ToC if the number of headers in the current page exceeds this number, otherwise render an empty widget
Example:
```toc
header: false
minHeaders: 1
```

View File

@ -54,3 +54,13 @@ where type = "query"
order by order
render [[template/documented-template]]
```
# Live Widget Templates
Use these to add [[Table of Contents]] and [[Linked Mentions]] to your pages.
```query
template
where type =~ /^widget:/ and name =~ /^template\//
order by order
render [[template/documented-template]]
```

View File

@ -5,7 +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]]
* [[Live Template Widgets]]
2. _One-off_ uses, where a template is instantiated once and inserted into an existing or new page:
* [[Slash Templates]]
* [[Page Templates]]
@ -25,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]] and to `frontmatter` for [[Live Frontmatter Templates]]
* `type` (optional): should be set to `page` for [[Page Templates]] and to `frontmatter` for [[Live Template Widgets]]
* `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

@ -1,6 +1,6 @@
---
tags: template
type: frontmatter
type: widget:top
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}}

View File

@ -0,0 +1,19 @@
---
description: Adds Linked Mentions to all pages
tags: template
type: widget:bottom
where: 'true'
---
```template
# We need to escape handlebars directives here, since we're embedding
# this template into a template (INCEPTION)
template: |
{{escape "#if ."}}
# Linked Mentions
{{escape "#each ."}}
* [[{{escape "ref"}}]]: `{{escape "snippet"}}`
{{escape "/each"}}
{{escape "/if"}}
query: |
link where toPage = "{{@page.name}}" and page != "{{@page.name}}"
```

View File

@ -0,0 +1,9 @@
---
description: Adds a Table of Contents to all pages
tags: template
type: widget:top
where: 'true'
---
```toc
minHeaders: 3
```