From fb21377bef1fd5d5b99f629741e9d14dc3b482e0 Mon Sep 17 00:00:00 2001 From: Zef Hemel Date: Tue, 9 Jul 2024 09:22:08 +0200 Subject: [PATCH] Transclosure support for pages (#925) Transclusion implementation --- common/query_functions.ts | 56 ++++++++++++++++--- plugs/markdown/api.ts | 44 +++++++++++++++ .../{inline_image.ts => inline_content.ts} | 49 ++++++++++++++-- web/editor_state.ts | 2 +- web/styles/editor.scss | 8 +++ website/Attachments.md | 2 + website/CHANGELOG.md | 1 + website/Expression Language.md | 4 +- website/Markdown/Extensions.md | 1 + website/Transclusion.md | 20 +++++++ website/internal/test page with template.md | 1 + website/internal/test page.md | 5 +- 12 files changed, 174 insertions(+), 19 deletions(-) rename web/cm_plugins/{inline_image.ts => inline_content.ts} (71%) create mode 100644 website/Transclusion.md create mode 100644 website/internal/test page with template.md diff --git a/common/query_functions.ts b/common/query_functions.ts index faf5fd16..0d1eee92 100644 --- a/common/query_functions.ts +++ b/common/query_functions.ts @@ -1,8 +1,8 @@ -import { FunctionMap } from "../plug-api/types.ts"; -import { builtinFunctions } from "../lib/builtin_query_functions.ts"; -import { System } from "../lib/plugos/system.ts"; -import { Query } from "../plug-api/types.ts"; +import { FunctionMap, Query } from "$sb/types.ts"; +import { builtinFunctions } from "$lib/builtin_query_functions.ts"; +import { System } from "$lib/plugos/system.ts"; import { LimitedMap } from "$lib/limited_map.ts"; +import { parsePageRef } from "$sb/lib/page_ref.ts"; const pageCacheTtl = 10 * 1000; // 10s @@ -49,20 +49,58 @@ export function buildQueryFunctions( variables, ]); }, - // INTERNAL: Used to implement resolving [[links]] in expressions - readPage(name: string): Promise | string { + // INTERNAL: Used to implement resolving [[links]] in expressions, also supports [[link#header]] and [[link$pos]] as well as [[link$anchor]] + async readPage(name: string): Promise { const cachedPage = pageCache.get(name); if (cachedPage) { return cachedPage; } else { - return system.localSyscall("space.readPage", [name]).then((page) => { + const pageRef = parsePageRef(name); + try { + let page: string = await system.localSyscall("space.readPage", [ + pageRef.page, + ]); + + // Extract page section if pos, anchor, or header are included + if (pageRef.pos) { + // If the page link includes a position, slice the page from that position + page = page.slice(pageRef.pos); + } else if (pageRef.anchor) { + // If the page link includes an anchor, slice the page from that anchor + const pos = page.indexOf(`$${pageRef.anchor}`); + page = page.slice(pos); + } else if (pageRef.header) { + // If the page link includes a header, select that header (up to the next header at the same level) + // Note: this an approximation, should ideally use the AST + let pos = page.indexOf(`# ${pageRef.header}\n`); + let headingLevel = 1; + while (page.charAt(pos - 1) === "#") { + pos--; + headingLevel++; + } + page = page.slice(pos); + + // Slice up to the next equal or higher level heading + const headRegex = new RegExp( + `[^#]#{1,${headingLevel}} [^\n]*\n`, + "g", + ); + const endPos = page.slice(headingLevel).search(headRegex) + + headingLevel; + if (endPos) { + page = page.slice(0, endPos); + } + } + pageCache.set(name, page, pageCacheTtl); + return page; - }).catch((e: any) => { + } catch (e: any) { if (e.message === "Not found") { throw new Error(`Page not found: ${name}`); } - }); + throw e; + } } }, }; diff --git a/plugs/markdown/api.ts b/plugs/markdown/api.ts index ae243a33..508ed084 100644 --- a/plugs/markdown/api.ts +++ b/plugs/markdown/api.ts @@ -10,6 +10,8 @@ import { MarkdownRenderOptions, renderMarkdownToHtml, } from "./markdown_render.ts"; +import { validatePageName } from "$sb/lib/page_ref.ts"; +import { parsePageRef } from "$sb/lib/page_ref.ts"; /** * Finds code widgets, runs their plug code to render and inlines their content in the parse tree @@ -58,6 +60,48 @@ export async function expandCodeWidgets( console.error("Error rendering code", e.message); } } + } else if (n.type === "Image") { + // Let's scan for ![[embeds]] that are codified as Images, confusingly + const wikiLinkMark = findNodeOfType(n, "WikiLinkMark"); + if (!wikiLinkMark) { + return; + } + const wikiLinkPage = findNodeOfType(n, "WikiLinkPage"); + if (!wikiLinkPage) { + return; + } + + const page = wikiLinkPage.children![0].text!; + + // Check if this is likely a page link (based on the path format, e.g. if it contains an extension, it's probably not a page link) + try { + const ref = parsePageRef(page); + validatePageName(ref.page); + } catch { + // Not a valid page name, so not a page reference + return; + } + + // Internally translate this to a template that inlines a page, then render that + const result = await codeWidget.render( + "template", + `{{[[${page}]]}}`, + page, + ); + if (!result) { + return { + text: "", + }; + } + // Only do this for "markdown" widgets, that is: that can render to markdown + if (result.markdown !== undefined) { + const parsedBody = await parseMarkdown(result.markdown); + // Recursively process + return expandCodeWidgets( + parsedBody, + page, + ); + } } }); return mdTree; diff --git a/web/cm_plugins/inline_image.ts b/web/cm_plugins/inline_content.ts similarity index 71% rename from web/cm_plugins/inline_image.ts rename to web/cm_plugins/inline_content.ts index 79d84021..581f1672 100644 --- a/web/cm_plugins/inline_image.ts +++ b/web/cm_plugins/inline_content.ts @@ -1,16 +1,18 @@ import { EditorState, Range } from "@codemirror/state"; import { syntaxTree } from "@codemirror/language"; import { Decoration, WidgetType } from "@codemirror/view"; +import { MarkdownWidget } from "./markdown_widget.ts"; import { decoratorStateField } from "./util.ts"; import type { Client } from "../client.ts"; -import { isLocalPath, resolvePath } from "$sb/lib/resolve.ts"; +import { isFederationPath, isLocalPath, resolvePath } from "$sb/lib/resolve.ts"; +import { parsePageRef } from "$sb/lib/page_ref.ts"; type ImageDimensions = { width?: number; height?: number; }; -class InlineImageWidget extends WidgetType { +class InlineContentWidget extends WidgetType { constructor( readonly url: string, readonly title: string, @@ -20,14 +22,13 @@ class InlineImageWidget extends WidgetType { super(); } - eq(other: InlineImageWidget) { + eq(other: InlineContentWidget) { return other.url === this.url && other.title === this.title && JSON.stringify(other.dim) === JSON.stringify(this.dim); } get estimatedHeight(): number { const cachedHeight = this.client.getCachedWidgetHeight(`image:${this.url}`); - // console.log("Estimated height requested", this.url, cachedHeight); return cachedHeight; } @@ -113,7 +114,9 @@ export function inlineImagesPlugin(client: Client) { (match = /(!?\[\[)([^\]\|]+)(?:\|([^\]]+))?(\]\])/g.exec(text)) ) { [/* fullMatch */, /* firstMark */ , url, alias] = match; - url = "/" + url; + if (!isFederationPath(url)) { + url = "/" + url; + } } else { return; } @@ -130,11 +133,45 @@ export function inlineImagesPlugin(client: Client) { if (isLocalPath(url)) { url = resolvePath(client.currentPage, decodeURI(url), true); + const pageRef = parsePageRef(url); + if ( + isFederationPath(pageRef.page) || + client.clientSystem.allKnownFiles.has(pageRef.page + ".md") + ) { + // This is a page reference, let's inline the content + const codeWidgetCallback = client.clientSystem.codeWidgetHook + .codeWidgetCallbacks.get("template"); + + if (!codeWidgetCallback) { + return; + } + + widgets.push( + Decoration.line({ + class: "sb-fenced-code-iframe", + }).range(node.to), + ); + + widgets.push( + Decoration.widget({ + widget: new MarkdownWidget( + node.from, + client, + `widget:${client.currentPage}:${text}`, + `{{[[${url}]]}}`, + codeWidgetCallback, + "sb-markdown-widget sb-markdown-widget-inline", + ), + block: true, + }).range(node.to), + ); + return; + } } widgets.push( Decoration.widget({ - widget: new InlineImageWidget(url, alias, dim, client), + widget: new InlineContentWidget(url, alias, dim, client), block: true, }).range(node.to), ); diff --git a/web/editor_state.ts b/web/editor_state.ts index b81569a7..bb8b09dd 100644 --- a/web/editor_state.ts +++ b/web/editor_state.ts @@ -27,7 +27,7 @@ import { import { vim } from "@replit/codemirror-vim"; import { markdown } from "@codemirror/lang-markdown"; import { Client } from "./client.ts"; -import { inlineImagesPlugin } from "./cm_plugins/inline_image.ts"; +import { inlineImagesPlugin } from "./cm_plugins/inline_content.ts"; import { cleanModePlugins } from "./cm_plugins/clean.ts"; import { lineWrapper } from "./cm_plugins/line_wrapper.ts"; import { smartQuoteKeymap } from "./cm_plugins/smart_quotes.ts"; diff --git a/web/styles/editor.scss b/web/styles/editor.scss index 5fdacf6b..cb5b6f2c 100644 --- a/web/styles/editor.scss +++ b/web/styles/editor.scss @@ -581,6 +581,14 @@ } } + .sb-markdown-widget-inline { + margin: 0 0 0 0; + + &:hover .button-bar { + display: none !important; + } + } + .sb-fenced-code-iframe { background-color: transparent; diff --git a/website/Attachments.md b/website/Attachments.md index 6eaed88b..56fec389 100644 --- a/website/Attachments.md +++ b/website/Attachments.md @@ -22,6 +22,8 @@ Images can also be embedded using the [[#Linking]] syntax, but prefixed with an These follow the same relative/absolute path rules as links described before. +## Image resizing + In addition, images can be _sized_ using the following syntax: * Specifying only a width: `![Alt text|300](image.png)` or `![[image.png|300]]` * Specifying both width and height: `![Hello|300x300](image.png)` or `![[image.png|300x300]]` diff --git a/website/CHANGELOG.md b/website/CHANGELOG.md index 5a533159..3580a3fb 100644 --- a/website/CHANGELOG.md +++ b/website/CHANGELOG.md @@ -7,6 +7,7 @@ release. _These features are not yet properly released, you need to use [the edge builds](https://community.silverbullet.md/t/living-on-the-edge-builds/27) to try them._ * The old **Template Picker** has now been rebranded to [[Meta Picker]] and surfaces pages in your space tagged as `#template` or `#meta`. Read more about this in [[Meta Pages]]. +* [[Transclusion]] has now been implementing, allowing inline embeddings of other pages as well as images (by onespaceman) using the convenient `![[link]]` syntax. * For new spaces, the default [[SETTINGS]] page is now tagged with `#meta`, which means it will only appear in the [[Meta Picker]]. There is also a new {[Navigate: Open SETTINGS]} command (bound to `Ctrl-,` and `Cmd-,`). * Attachments are now indexed, and smartly moved when pages are renamed (by onespaceman) * Images can now be resized: [[Attachments#Embedding]] (initial work done by [Florent](https://github.com/silverbulletmd/silverbullet/pull/833), later adapted by onespaceman) diff --git a/website/Expression Language.md b/website/Expression Language.md index 02531153..63c77fae 100644 --- a/website/Expression Language.md +++ b/website/Expression Language.md @@ -63,9 +63,9 @@ A rendered query: Page references use the `[[page name]]` syntax and evaluate to the content of the referenced page (as a string), this makes them a good candidate to be used in conjunction with [[Functions#template(text, value)]] or to simply inline another page: ```template -Including another page directly, without template rendering: {{[[internal/test page]]}} +Including another page directly, without template rendering: {{[[internal/test page with template]]}} -And rendered as a template: {{template([[internal/test page]], "actual value")}} +And rendered as a template: {{template([[internal/test page with template]], "actual value")}} ``` # Logical expressions diff --git a/website/Markdown/Extensions.md b/website/Markdown/Extensions.md index dcbc23c6..bf6913c1 100644 --- a/website/Markdown/Extensions.md +++ b/website/Markdown/Extensions.md @@ -7,6 +7,7 @@ In addition to supporting [[Markdown/Basics|markdown basics]] as standardized by * [[Live Queries]] * [[Live Templates]] * [[Table of Contents]] +* [[Transclusion]] syntax * [[Markdown/Anchors]] * [[Markdown/Admonitions]] * Hashtags, e.g. `#mytag`. diff --git a/website/Transclusion.md b/website/Transclusion.md new file mode 100644 index 00000000..bfbf59c2 --- /dev/null +++ b/website/Transclusion.md @@ -0,0 +1,20 @@ +Transclusions are an extension of the [[Markdown]] syntax enabling inline embedding of content. + +The general syntax is `![[path]]`. Two types of transclusions are currently supported: + +# Images +Syntax: `![[path/to/image.jpg]]` see [[Attachments#Embedding]] for more details. + +Image resizing is also supported: +![[Attachments#Image resizing]] +# Pages +Syntax: +* `![[page name]]` embed an entire page +* `![[page name#header]]` embed only a section (guarded by the given header) +* `![[page$anchor]]` embed a page _from_ the given anchor until the end of the page +* `![[page@pos]]` embed a page _from_ the given character position until the end of the page + +For example, to embed a full page: +![[internal/test page]] +And just a header: +![[internal/test page#This is a header]] \ No newline at end of file diff --git a/website/internal/test page with template.md b/website/internal/test page with template.md new file mode 100644 index 00000000..ee5a6a46 --- /dev/null +++ b/website/internal/test page with template.md @@ -0,0 +1 @@ +This is a simple test page with a placeholder: {{.}}. \ No newline at end of file diff --git a/website/internal/test page.md b/website/internal/test page.md index ee5a6a46..cd310c47 100644 --- a/website/internal/test page.md +++ b/website/internal/test page.md @@ -1 +1,4 @@ -This is a simple test page with a placeholder: {{.}}. \ No newline at end of file +This is a simple **test page**. Cool, no? + +# This is a header +And some content underneath \ No newline at end of file