diff --git a/apps/client/src/widgets/type_widgets/llm_chat/ChatMessage.tsx b/apps/client/src/widgets/type_widgets/llm_chat/ChatMessage.tsx index f385339519..26a7f36c46 100644 --- a/apps/client/src/widgets/type_widgets/llm_chat/ChatMessage.tsx +++ b/apps/client/src/widgets/type_widgets/llm_chat/ChatMessage.tsx @@ -1,10 +1,10 @@ import "./ChatMessage.css"; import DOMPurify from "dompurify"; -import { Marked, type TokenizerAndRendererExtension } from "marked"; +import { Marked } from "marked"; import { useEffect, useMemo, useRef } from "preact/hooks"; -import type { LlmCitation } from "@triliumnext/commons"; +import { type LlmCitation, createWikiLinkExtension } from "@triliumnext/commons"; import link from "../../../services/link.js"; import { t } from "../../../services/i18n.js"; @@ -19,34 +19,14 @@ function shortenNumber(n: number): string { return n.toString(); } -/** Wiki-link extension for internal note links: [[noteId]] */ -const wikiLinkExtension: TokenizerAndRendererExtension = { - name: "wikiLink", - level: "inline", - start(src) { - return src.indexOf("[["); - }, - tokenizer(src) { - const match = /^\[\[([^\]]+?)\]\]/.exec(src); - if (match) { - return { - type: "wikiLink", - raw: match[0], - href: match[1].trim() - }; - } - }, - renderer(token) { - return `${token.href}`; - } -}; - -// Configure marked for safe rendering +// Configure marked for safe rendering with client-side URL format const markedInstance = new Marked({ breaks: true, // Convert \n to
gfm: true // GitHub Flavored Markdown }); -markedInstance.use({ extensions: [wikiLinkExtension] }); +markedInstance.use({ + extensions: [createWikiLinkExtension({ formatHref: (id) => `#root/${id}` })] +}); /** Parse markdown to HTML. */ function renderMarkdown(markdown: string): string { diff --git a/apps/server/src/services/import/markdown.ts b/apps/server/src/services/import/markdown.ts index e91670df66..a812567887 100644 --- a/apps/server/src/services/import/markdown.ts +++ b/apps/server/src/services/import/markdown.ts @@ -1,14 +1,11 @@ -import { getMimeTypeFromMarkdownName, MIME_TYPE_AUTO } from "@triliumnext/commons"; -import { normalizeMimeTypeForCKEditor } from "@triliumnext/commons"; -import { parse, Renderer, type Tokens,use } from "marked"; +import { getMimeTypeFromMarkdownName, MIME_TYPE_AUTO, normalizeMimeTypeForCKEditor, transclusionExtension, wikiLinkExtension } from "@triliumnext/commons"; +import { parse, Renderer, type Tokens, use } from "marked"; import { ADMONITION_TYPE_MAPPINGS } from "../export/markdown.js"; import htmlSanitizer from "../html_sanitizer.js"; import utils from "../utils.js"; -import wikiLinkInternalLink from "./markdown/wikilink_internal_link.js"; -import wikiLinkTransclusion from "./markdown/wikilink_transclusion.js"; import importUtils from "./utils.js"; const escape = utils.escapeHtml; @@ -136,8 +133,8 @@ function renderToHtml(content: string, title: string) { use({ // Order is important, especially for wikilinks. extensions: [ - wikiLinkTransclusion, - wikiLinkInternalLink + transclusionExtension, + wikiLinkExtension ] }); diff --git a/apps/server/src/services/import/markdown/wikilink_internal_link.ts b/apps/server/src/services/import/markdown/wikilink_internal_link.ts deleted file mode 100644 index dde0847001..0000000000 --- a/apps/server/src/services/import/markdown/wikilink_internal_link.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { TokenizerAndRendererExtension } from "marked"; - -const wikiLinkInternalLink: TokenizerAndRendererExtension = { - name: "wikilinkInternalLink", - level: "inline", - - start(src: string) { - return src.indexOf('[['); - }, - - tokenizer(src) { - const match = /^\[\[([^\]]+?)\]\]/.exec(src); - if (match) { - return { - type: 'wikilinkInternalLink', - raw: match[0], - text: match[1].trim(), // what shows as link text - href: match[1].trim() - }; - } - }, - - renderer(token) { - return `${token.text}`; - } - -} - -export default wikiLinkInternalLink; diff --git a/apps/server/src/services/import/markdown/wikilink_transclusion.ts b/apps/server/src/services/import/markdown/wikilink_transclusion.ts deleted file mode 100644 index e15f9426a5..0000000000 --- a/apps/server/src/services/import/markdown/wikilink_transclusion.ts +++ /dev/null @@ -1,30 +0,0 @@ -import type { TokenizerAndRendererExtension } from "marked"; - -/** - * The terminology is inspired by https://silverbullet.md/Transclusions. - */ -const wikiLinkTransclusion: TokenizerAndRendererExtension = { - name: "wikiLinkTransclusion", - level: "inline", - - start(src: string) { - return src.match(/!\[\[/)?.index; - }, - - tokenizer(src) { - const match = /^!\[\[([^\]]+?)\]\]/.exec(src); - if (match) { - return { - type: "wikiLinkTransclusion", - raw: match[0], - href: match[1].trim(), - }; - } - }, - - renderer(token) { - return ``; - } -}; - -export default wikiLinkTransclusion; diff --git a/packages/commons/package.json b/packages/commons/package.json index b1505e50a4..2fbf998a84 100644 --- a/packages/commons/package.json +++ b/packages/commons/package.json @@ -16,6 +16,7 @@ }, "dependencies": { "dayjs": "1.11.20", - "dayjs-plugin-utc": "0.1.2" + "dayjs-plugin-utc": "0.1.2", + "marked": "17.0.5" } } \ No newline at end of file diff --git a/packages/commons/src/index.ts b/packages/commons/src/index.ts index faae4922eb..f9e000aeba 100644 --- a/packages/commons/src/index.ts +++ b/packages/commons/src/index.ts @@ -17,3 +17,4 @@ export * from "./lib/week_utils.js"; export { default as BUILTIN_ATTRIBUTES } from "./lib/builtin_attributes.js"; export * from "./lib/spreadsheet/render_to_html.js"; export * from "./lib/llm_api.js"; +export * from "./lib/marked_extensions.js"; diff --git a/packages/commons/src/lib/marked_extensions.ts b/packages/commons/src/lib/marked_extensions.ts new file mode 100644 index 0000000000..3522ace7b4 --- /dev/null +++ b/packages/commons/src/lib/marked_extensions.ts @@ -0,0 +1,94 @@ +import type { TokenizerAndRendererExtension } from "marked"; + +export interface WikiLinkOptions { + /** Format the href for the link. Defaults to `/${noteId}` */ + formatHref?: (noteId: string) => string; +} + +/** + * Creates a wiki-link extension for internal note links: [[noteId]] + * + * @example + * // Server-side (for import) + * createWikiLinkExtension() // uses default /${noteId} + * + * // Client-side (for navigation) + * createWikiLinkExtension({ formatHref: (id) => `#root/${id}` }) + */ +export function createWikiLinkExtension(options: WikiLinkOptions = {}): TokenizerAndRendererExtension { + const formatHref = options.formatHref ?? ((id) => `/${id}`); + + return { + name: "wikiLink", + level: "inline", + + start(src: string) { + return src.indexOf("[["); + }, + + tokenizer(src) { + const match = /^\[\[([^\]]+?)\]\]/.exec(src); + if (match) { + return { + type: "wikiLink", + raw: match[0], + text: match[1].trim(), + href: match[1].trim() + }; + } + }, + + renderer(token) { + const noteId = token.href as string; + return `${token.text}`; + } + }; +} + +export interface TransclusionOptions { + /** Format the src for the image/embed. Defaults to `/${noteId}` */ + formatSrc?: (noteId: string) => string; +} + +/** + * Creates a transclusion extension for embedding note content: ![[noteId]] + * Terminology inspired by https://silverbullet.md/Transclusions + * + * @example + * createTransclusionExtension() // uses default /${noteId} + * createTransclusionExtension({ formatSrc: (id) => `/api/images/${id}` }) + */ +export function createTransclusionExtension(options: TransclusionOptions = {}): TokenizerAndRendererExtension { + const formatSrc = options.formatSrc ?? ((id) => `/${id}`); + + return { + name: "transclusion", + level: "inline", + + start(src: string) { + return src.match(/!\[\[/)?.index; + }, + + tokenizer(src) { + const match = /^!\[\[([^\]]+?)\]\]/.exec(src); + if (match) { + return { + type: "transclusion", + raw: match[0], + href: match[1].trim() + }; + } + }, + + renderer(token) { + const noteId = token.href as string; + return ``; + } + }; +} + +/** Pre-configured wiki-link extension for server-side (uses /noteId format) */ +export const wikiLinkExtension = createWikiLinkExtension(); + +/** Pre-configured transclusion extension for server-side (uses /noteId format) */ +export const transclusionExtension = createTransclusionExtension();