diff --git a/packages/cli/src/commands/clone.ts b/packages/cli/src/commands/clone.ts index 98277b6..f8af999 100644 --- a/packages/cli/src/commands/clone.ts +++ b/packages/cli/src/commands/clone.ts @@ -165,6 +165,14 @@ Examples: if (!row || row.folder == null || row.slug == null) return undefined; return row.folder ? `${row.folder}/${row.slug}` : row.slug; }; + const entityTitleLookup = (externalId: string): string | undefined => { + const [row] = colDb + .select({ title: entities.title }) + .from(entities) + .where(eq(entities.externalId, externalId)) + .all(); + return row?.title ?? undefined; + }; const byExtId = new Map(); const byStem = new Map(); @@ -205,6 +213,7 @@ Examples: collectionName: localName, crawlerType, lookupEntityPath: entityPathLookup, + lookupEntityTitle: entityTitleLookup, resolveWikilink, }; const derivedTitle = themeEngine.getTitle(ctx); diff --git a/packages/cli/src/commands/generate.ts b/packages/cli/src/commands/generate.ts index e664fd0..61bc1f6 100644 --- a/packages/cli/src/commands/generate.ts +++ b/packages/cli/src/commands/generate.ts @@ -109,6 +109,14 @@ export async function generateCollection( if (!r || r.folder == null || r.slug == null) return undefined; return r.folder ? `${r.folder}/${r.slug}` : r.slug; }; + const entityTitleLookup = (externalId: string): string | undefined => { + const rows = colDb + .select({ title: entities.title }) + .from(entities) + .where(eq(entities.externalId, externalId)) + .all(); + return rows[0]?.title ?? undefined; + }; // Build stem-matching wikilink resolver (for Obsidian [[bare name]] links) const allEntityRows = colDb @@ -164,6 +172,7 @@ export async function generateCollection( collectionName: col.name, crawlerType: col.crawler, lookupEntityPath: entityPathLookup, + lookupEntityTitle: entityTitleLookup, resolveWikilink, }; const derivedTitle = themeEngine.getTitle(baseCtx); diff --git a/packages/cli/src/commands/prepare.ts b/packages/cli/src/commands/prepare.ts index fddcabc..c6d5044 100644 --- a/packages/cli/src/commands/prepare.ts +++ b/packages/cli/src/commands/prepare.ts @@ -36,6 +36,18 @@ function makeEntityPathLookup(colDb: ColDb): (id: string) => string | undefined }; } +/** Build entity title lookup for theme cross-reference labelling. */ +function makeEntityTitleLookup(colDb: ColDb): (id: string) => string | undefined { + return (externalId: string) => { + const rows = colDb + .select({ title: entities.title }) + .from(entities) + .where(eq(entities.externalId, externalId)) + .all(); + return rows[0]?.title ?? undefined; + }; +} + /** Build stem-matching wikilink resolver (for Obsidian-style [[bare name]] links). */ function makeResolveWikilink(colDb: ColDb): (target: string) => string | undefined { const allRows = colDb @@ -240,6 +252,7 @@ async function checkSamples( .all(); const lookupEntityPath = makeEntityPathLookup(colDb); + const lookupEntityTitle = makeEntityTitleLookup(colDb); const resolveWikilink = makeResolveWikilink(colDb); let sampled = 0; @@ -273,6 +286,7 @@ async function checkSamples( collectionName, crawlerType, lookupEntityPath, + lookupEntityTitle, resolveWikilink, }; diff --git a/packages/cli/src/commands/serve.ts b/packages/cli/src/commands/serve.ts index 9b70f7d..af80bdc 100644 --- a/packages/cli/src/commands/serve.ts +++ b/packages/cli/src/commands/serve.ts @@ -506,6 +506,14 @@ export function createApiServer( if (!row || row.folder == null || row.slug == null) return undefined; return row.folder ? `${row.folder}/${row.slug}` : row.slug; }; + const lookupEntityTitle = (externalId: string): string | undefined => { + const [row] = colDb + .select({ title: entities.title }) + .from(entities) + .where(eq(entities.externalId, externalId)) + .all(); + return row?.title ?? undefined; + }; const markdown = themeEngine.render({ entity: { @@ -519,6 +527,7 @@ export function createApiServer( collectionName: name, crawlerType, lookupEntityPath, + lookupEntityTitle, }); return new Response(markdown, { @@ -568,6 +577,14 @@ export function createApiServer( if (!row || row.folder == null || row.slug == null) return undefined; return row.folder ? `${row.folder}/${row.slug}` : row.slug; }; + const lookupEntityTitle = (externalId: string): string | undefined => { + const [row] = colDb + .select({ title: entities.title }) + .from(entities) + .where(eq(entities.externalId, externalId)) + .all(); + return row?.title ?? undefined; + }; const html = themeEngine.renderHtml({ entity: { @@ -581,6 +598,7 @@ export function createApiServer( collectionName: name, crawlerType: htmlCrawlerType, lookupEntityPath, + lookupEntityTitle, }); if (!html) return errorResponse("HTML rendering not available", 404); diff --git a/packages/core/src/crawler/sync-engine.ts b/packages/core/src/crawler/sync-engine.ts index 0657f6f..f0c89aa 100644 --- a/packages/core/src/crawler/sync-engine.ts +++ b/packages/core/src/crawler/sync-engine.ts @@ -819,6 +819,17 @@ export class SyncEngine { }; } + private makeLookupEntityTitle(): (externalId: string) => string | undefined { + return (externalId: string) => { + const rows = this.db + .select({ title: entities.title }) + .from(entities) + .where(eq(entities.externalId, externalId)) + .all(); + return rows[0]?.title ?? undefined; + }; + } + /** * Build a wikilink resolver that supports Obsidian-style stem matching. * "Topic" matches an entity whose externalId ends with "/Topic.md" or is "Topic.md". @@ -877,6 +888,7 @@ export class SyncEngine { collectionName: this.collectionName, crawlerType, lookupEntityPath: this.makeLookupEntityPath(), + lookupEntityTitle: this.makeLookupEntityTitle(), resolveWikilink: this.makeResolveWikilink(), }); } diff --git a/packages/core/src/theme/interface.ts b/packages/core/src/theme/interface.ts index c515dad..eba97b3 100644 --- a/packages/core/src/theme/interface.ts +++ b/packages/core/src/theme/interface.ts @@ -50,6 +50,12 @@ export interface ThemeRenderContext { * Used by themes to generate correct cross-reference wikilinks. */ lookupEntityPath?: (externalId: string) => string | undefined; + /** + * Look up the human-readable display title for any entity by its externalId. + * Themes can use this to label cross-references with the target's title rather + * than its slug/page-name. Returns undefined if the entity is unknown. + */ + lookupEntityTitle?: (externalId: string) => string | undefined; /** * Resolve an Obsidian-style wikilink target (bare name or path) to a markdown * file path (relative to markdown root, without .md extension). diff --git a/packages/crawlers/src/mantishub/__tests__/theme.test.ts b/packages/crawlers/src/mantishub/__tests__/theme.test.ts index ec3b3b9..ba9f0ba 100644 --- a/packages/crawlers/src/mantishub/__tests__/theme.test.ts +++ b/packages/crawlers/src/mantishub/__tests__/theme.test.ts @@ -10,6 +10,14 @@ const lookup = (externalId: string): string | undefined => { "issue:100": "issues/00100-linked-issue", "issue:200": "issues/00200-another-issue", "user:alice": "users/alice", + "page:1:onboarding-guide": "testproject/pages/onboarding-guide", + }; + return map[externalId]; +}; + +const titleLookup = (externalId: string): string | undefined => { + const map: Record = { + "page:1:onboarding-guide": "Onboarding Guide for New Engineers", }; return map[externalId]; }; @@ -131,6 +139,7 @@ describe("MantisHubTheme HTML page rendering", () => { collectionName: "test", crawlerType: "mantishub", lookupEntityPath: lookup, + lookupEntityTitle: titleLookup, }; } @@ -150,6 +159,192 @@ describe("MantisHubTheme HTML page rendering", () => { expect(html).toContain('class="mt-issue-ref"'); expect(html).toContain("#wikilink/issues%2F00100-linked-issue"); }); + + it("does not duplicate the page heading in markdown when content starts with H1", () => { + const md = theme.render({ + entity: { + externalId: "page:1:getting-started", + entityType: "page", + title: "Getting Started", + data: { + id: 1, name: "getting-started", title: "Getting Started", + project: { id: 1, name: "TestProject" }, + content: "# Getting Started\n\nWelcome.", + }, + }, + collectionName: "test", + crawlerType: "mantishub", + lookupEntityPath: lookup, + }); + // Only one occurrence of the heading text in body (excluding frontmatter). + const body = md.replace(/^---[\s\S]*?---\n/, ""); + const matches = body.match(/Getting Started/g) ?? []; + expect(matches.length).toBe(1); + }); + + it("does not render duplicate H1 when content starts with matching title", () => { + const html = theme.renderHtml!(makePageContext("# Getting Started\n\nWelcome.")); + // Title H1 should appear exactly once + const h1Count = (html.match(/]/g) ?? []).length; + expect(h1Count).toBe(1); + expect(html).toContain("Welcome."); + }); + + it("uses the content H1 when it differs from the stored title", () => { + const html = theme.renderHtml!(makePageContext("# Onboarding Guide\n\nBody.")); + expect(html).toContain('class="mt-title">Onboarding Guide'); + const h1Count = (html.match(/]/g) ?? []).length; + expect(h1Count).toBe(1); + }); + + it("uses the target page title as wiki-link label when available", () => { + const html = theme.renderHtml!(makePageContext("See [[onboarding-guide]] for details.")); + expect(html).toContain('class="mt-page-link"'); + expect(html).toContain("Onboarding Guide for New Engineers"); + // The slug should not appear as the visible label (only inside the URL fragment). + expect(html).not.toMatch(/>onboarding-guide { + const md = "* **Droplet**\n Shut down the droplet.\n* **IP**\n Release the reserved IP."; + const html = theme.renderHtml!(makePageContext(md)); + expect(html).toContain("
  • Droplet
    Shut down the droplet.
  • "); + expect(html).toContain("
  • IP
    Release the reserved IP.
  • "); + }); + + it("renders --- as a horizontal rule", () => { + const html = theme.renderHtml!(makePageContext("Above\n\n---\n\n## Below")); + expect(html).toContain('
    '); + expect(html).toContain("'", () => { + const html = theme.renderHtml!(makePageContext(" > [!NOTE] \n> Legacy doco")); + expect(html).toContain("mt-md-callout-note"); + expect(html).toContain("Legacy doco"); + expect(html).not.toContain("[!NOTE]"); + }); + + it("tolerates malformed blockquote lines without splitting the quote", () => { + const html = theme.renderHtml!(makePageContext("> First line\n>. \n> Third line")); + expect((html.match(/
    { + const l = theme.renderHtml!(makePageContext("![Demo](https://www.loom.com/share/xyz)")); + expect(l).toContain('src="https://www.loom.com/embed/xyz"'); + const y = theme.renderHtml!(makePageContext("![Demo](https://www.youtube.com/watch?v=dQw4w9WgXcQ)")); + expect(y).toContain('src="https://www.youtube.com/embed/dQw4w9WgXcQ"'); + const v = theme.renderHtml!(makePageContext("![Demo](https://vimeo.com/76979871)")); + expect(v).toContain('src="https://player.vimeo.com/video/76979871"'); + }); + + it("renders Komodo recordings as a clickable play-card link (no iframe)", () => { + const k = theme.renderHtml!(makePageContext("![How to](https://komododecks.com/recordings/abc123)")); + expect(k).toContain('class="mt-md-video-link"'); + expect(k).toContain('href="https://kommodo.ai/recordings/abc123"'); + expect(k).toContain("How to"); + expect(k).not.toContain(" { + const md = theme.render({ + entity: { + externalId: "page:1:demo", + entityType: "page", + title: "Demo", + data: { + id: 1, name: "demo", title: "Demo", + project: { id: 1, name: "TestProject" }, + content: "![How](https://www.youtube.com/watch?v=dQw4w9WgXcQ)", + }, + }, + collectionName: "test", + crawlerType: "mantishub", + lookupEntityPath: lookup, + }); + expect(md).toContain("![How](https://www.youtube.com/watch?v=dQw4w9WgXcQ)"); + expect(md).not.toContain(" in HTML", () => { + const html = theme.renderHtml!(makePageContext("![Screenshot](https://example.com/x.png)")); + expect(html).toContain('Screenshot { + const md = "1. First\n\n2. Second\n 1. Sub a\n 2. Sub b\n\n3. Third\n4. Fourth"; + const html = theme.renderHtml!(makePageContext(md)); + // Single outer
      + expect((html.match(/]/g) ?? []).length).toBe(2); // outer + 1 nested + expect((html.match(/<\/ol>/g) ?? []).length).toBe(2); + // Outer should contain First, Second, Third, Fourth in one list + expect(html).toMatch(/
        .*First.*Second.*Third.*Fourth.*<\/ol>/s); + // Nested ol inside Second's
      1. + expect(html).toMatch(/Second]*>
      2. Sub a<\/li>
      3. Sub b<\/li><\/ol>/); + }); + + it("renders task list items as checkboxes", () => { + const md = "- [x] Done thing\n- [ ] Open thing\n- Plain item"; + const html = theme.renderHtml!(makePageContext(md)); + expect(html).toContain(''); + expect(html).toContain(''); + expect(html).toContain('class="mt-md-task mt-md-task-done"'); + expect(html).toContain('class="mt-md-task"'); + expect(html).toContain("mt-md-task-list"); + // Plain item retains no task class + expect(html).toContain("
      4. Plain item
      5. "); + // No literal bracket prefix on task items + expect(html).not.toMatch(/]*>\[/); + }); + + it("renders GFM > [!NOTE] admonitions as styled callouts with icons", () => { + const md = "> [!NOTE]\n> Heads up.\n\n> [!WARNING]\n> Careful here."; + const html = theme.renderHtml!(makePageContext(md)); + expect(html).toContain("mt-md-callout-note"); + expect(html).toContain('class="mt-md-callout-title"'); + expect(html).toContain("Note<"); + expect(html).toContain("Heads up."); + expect(html).toContain("mt-md-callout-warning"); + expect(html).toContain("Careful here."); + expect(html).not.toContain("[!NOTE]"); + }); + + it("recognises shorthand [Note] / [Warning] without the GFM '!' prefix", () => { + const md = "> [Note]\n> Plain note text.\n\n> [Warning]\n> Plain warning text."; + const html = theme.renderHtml!(makePageContext(md)); + expect(html).toContain("mt-md-callout-note"); + expect(html).toContain("mt-md-callout-warning"); + expect(html).toContain("Plain note text."); + expect(html).toContain("Plain warning text."); + expect(html).not.toContain("[Note]"); + expect(html).not.toContain("[Warning]"); + }); + + it("renders nested bullet lists with proper indentation", () => { + const md = "- Top one\n - Child A\n - Child B\n - Grand\n- Top two"; + const html = theme.renderHtml!(makePageContext(md)); + // Outer list with class, inner list without + expect(html).toContain('
          '); + // Three
            opens (root + 2 nested) and three closes + expect((html.match(/
              /g) ?? []).length).toBe(3); + // The grand-child should be nested inside Child B's
            • + expect(html).toContain("Child B
              • Grand
              "); + }); + + it("renders unresolved [[wiki-link]] as a missing-page indicator (not literal)", () => { + const html = theme.renderHtml!(makePageContext("See [[nonexistent-page]] for details.")); + expect(html).toContain("mt-page-missing"); + expect(html).toContain("nonexistent-page"); + expect(html).not.toContain("[[nonexistent-page]]"); + }); + }); describe("MantisHubTheme getFilePath", () => { diff --git a/packages/crawlers/src/mantishub/theme.ts b/packages/crawlers/src/mantishub/theme.ts index 92f8e82..2ef8196 100644 --- a/packages/crawlers/src/mantishub/theme.ts +++ b/packages/crawlers/src/mantishub/theme.ts @@ -132,6 +132,165 @@ function mtAvatar(name: string, avatarUrl?: string | null, size = 24): string { type Lookup = (externalId: string) => string | undefined; +/** Inline SVG (Octicon-style) icon for a GFM admonition type. */ +function calloutIcon(type: string): string { + const paths: Record = { + note: '', + tip: '', + important: '', + warning: '', + caution: '', + }; + const p = paths[type] ?? paths.note; + return `${p}`; +} + +/** + * Detect a video-sharing URL and return either an iframe embed (Loom, YouTube, + * Vimeo) or a clickable play-card that opens the source in a new tab (Komodo, + * which has no public iframe-embed URL on its free tier). + * Returns null for non-video URLs. + */ +export function videoEmbedHtml(url: string, alt: string): string | null { + let m: RegExpMatchArray | null; + if ((m = url.match(/^https?:\/\/(?:www\.)?(?:komododecks\.com|kommodo\.ai)\/recordings\/([\w-]+)/))) { + const playUrl = `https://kommodo.ai/recordings/${m[1]}`; + const label = esc(alt || "Watch recording"); + return `` + + `${label}` + + ``; + } + if ((m = url.match(/^https?:\/\/(?:www\.)?loom\.com\/share\/([\w-]+)/))) { + return ``; + } + if ((m = url.match(/^https?:\/\/(?:www\.)?youtube\.com\/watch\?v=([\w-]+)/)) || + (m = url.match(/^https?:\/\/youtu\.be\/([\w-]+)/))) { + return ``; + } + if ((m = url.match(/^https?:\/\/(?:www\.)?vimeo\.com\/(\d+)/))) { + return ``; + } + return null; +} + +/** + * Detect a video-sharing URL and return its embed metadata for use by the + * markdown viewer's React `img` component. Returns null for non-video URLs. + */ +export function videoEmbedInfo(url: string): { provider: "komodo" | "loom" | "youtube" | "vimeo"; embedUrl: string; openUrl: string } | null { + let m: RegExpMatchArray | null; + if ((m = url.match(/^https?:\/\/(?:www\.)?(?:komododecks\.com|kommodo\.ai)\/recordings\/([\w-]+)/))) { + const open = `https://kommodo.ai/recordings/${m[1]}`; + return { provider: "komodo", embedUrl: open, openUrl: open }; + } + if ((m = url.match(/^https?:\/\/(?:www\.)?loom\.com\/share\/([\w-]+)/))) { + return { provider: "loom", embedUrl: `https://www.loom.com/embed/${m[1]}`, openUrl: `https://www.loom.com/share/${m[1]}` }; + } + if ((m = url.match(/^https?:\/\/(?:www\.)?youtube\.com\/watch\?v=([\w-]+)/)) || + (m = url.match(/^https?:\/\/youtu\.be\/([\w-]+)/))) { + return { provider: "youtube", embedUrl: `https://www.youtube.com/embed/${m[1]}`, openUrl: `https://www.youtube.com/watch?v=${m[1]}` }; + } + if ((m = url.match(/^https?:\/\/(?:www\.)?vimeo\.com\/(\d+)/))) { + return { provider: "vimeo", embedUrl: `https://player.vimeo.com/video/${m[1]}`, openUrl: `https://vimeo.com/${m[1]}` }; + } + return null; +} + +/** + * Build a nested list tree (unordered and/or ordered) from a contiguous block. + * Indentation (tabs expand to 2 spaces) determines nesting; sibling items at + * the same indent inherit the marker style of the first sibling. Single blank + * lines between items are tolerated (the list is not split). + */ +type ListItem = { indent: number; ordered: boolean; orderStart?: number; content: string }; + +function buildNestedList(block: string): string { + const rawLines = block.replace(/\n+$/, "").split("\n"); + const items: ListItem[] = []; + for (const line of rawLines) { + if (!line.trim()) continue; // skip blank lines that separate items + const ul = line.match(/^([ \t]*)[-*+] (.+)$/); + if (ul) { + items.push({ + indent: ul[1].replace(/\t/g, " ").length, + ordered: false, + content: ul[2], + }); + continue; + } + const ol = line.match(/^([ \t]*)(\d+)\. (.+)$/); + if (ol) { + items.push({ + indent: ol[1].replace(/\t/g, " ").length, + ordered: true, + orderStart: parseInt(ol[2], 10), + content: ol[3], + }); + continue; + } + // Lazy continuation: an indented non-marker line belongs to the + // previous item (the bold-title-then-explanation pattern). + if (items.length) { + items[items.length - 1].content += `
              ${line.trim()}`; + } + } + if (!items.length) return ""; + + let i = 0; + const baseIndent = items[0].indent; + + function renderItem(content: string): { liClass: string; html: string } { + const task = content.match(/^\[( |x|X)\]\s+(.*)$/); + if (task) { + const checked = task[1].toLowerCase() === "x"; + return { + liClass: ` class="mt-md-task${checked ? " mt-md-task-done" : ""}"`, + html: ` ${task[2]}`, + }; + } + return { liClass: "", html: content }; + } + + function build(level: number, isRoot: boolean): string { + if (i >= items.length) return ""; + const ordered = items[i].ordered; + const start = items[i].orderStart; + const tag = ordered ? "ol" : "ul"; + const inner: string[] = []; + let hasTask = false; + while (i < items.length && items[i].indent >= level) { + const item = items[i]; + if (item.indent > level) break; + // If a sibling switches marker style, end the current list. + if (item.indent === level && item.ordered !== ordered) break; + i++; + const rendered = renderItem(item.content); + if (rendered.liClass) hasTask = true; + const next = items[i]; + if (next && next.indent > level) { + const nested = build(next.indent, false); + inner.push(`${rendered.html}${nested}
            • `); + } else { + inner.push(`${rendered.html}`); + } + } + const classes: string[] = []; + if (isRoot) classes.push("mt-md-list"); + if (hasTask) classes.push("mt-md-task-list"); + const classAttr = classes.length ? ` class="${classes.join(" ")}"` : ""; + const startAttr = ordered && start !== undefined && start !== 1 ? ` start="${start}"` : ""; + return `<${tag}${classAttr}${startAttr}>${inner.join("")}`; + } + + // Build top-level lists; if marker style flips at the root indent, emit + // adjacent sibling lists rather than collapsing them. + const out: string[] = []; + while (i < items.length && items[i].indent === baseIndent) { + out.push(build(baseIndent, true)); + } + return out.join(""); +} + /** * Convert plain text to HTML with escaped entities, line breaks, * and #1234 issue references / @mentions converted to wikilinks. @@ -196,6 +355,7 @@ function markdownToHtml( text: string, lookup?: Lookup, projectId?: number, + titleLookup?: Lookup, ): string { let raw = text.replace(/\r\n/g, "\n"); @@ -211,8 +371,20 @@ function markdownToHtml( return `\x00CODEBLOCK${idx}\x00`; }); - // ── Step 2: extract markdown links [text](url) before escaping ── + // ── Step 2a: extract image syntax ![alt](url) before regular links ── const links: string[] = []; + raw = raw.replace(/!\[([^\]]*)\]\((https?:\/\/[^)]+)\)/g, (_m, alt: string, url: string) => { + const idx = links.length; + const embed = videoEmbedHtml(url, alt); + if (embed) { + links.push(embed); + } else { + links.push(`${esc(alt)}`); + } + return `\x00LINK${idx}\x00`; + }); + + // ── Step 2b: extract markdown links [text](url) before escaping ── raw = raw.replace(/\[([^\]]+)\]\((https?:\/\/[^)]+)\)/g, (_m, linkText: string, url: string) => { const idx = links.length; links.push(`${esc(linkText)}`); @@ -230,11 +402,26 @@ function markdownToHtml( }); // Same-project: [[page-name]] raw = raw.replace(/\[\[([\w-]+)\]\]/g, (_m, pageName: string) => { - if (!projectId) return `[[${pageName}]]`; - const path = lookup(`page:${projectId}:${pageName}`); - if (!path) return `[[${pageName}]]`; + const externalId = projectId ? `page:${projectId}:${pageName}` : undefined; + const path = externalId ? lookup(externalId) : undefined; + const idx = links.length; + if (!path) { + links.push(`${esc(pageName)}`); + } else { + const label = (externalId && titleLookup?.(externalId)) || pageName; + links.push(`${esc(label)}`); + } + return `\x00LINK${idx}\x00`; + }); + } else { + raw = raw.replace(/\[\[\/(.+?)\/([\w-]+)\]\]/g, (_m, projName: string, pageName: string) => { + const idx = links.length; + links.push(`${esc(projName)}/${esc(pageName)}`); + return `\x00LINK${idx}\x00`; + }); + raw = raw.replace(/\[\[([\w-]+)\]\]/g, (_m, pageName: string) => { const idx = links.length; - links.push(`${esc(pageName)}`); + links.push(`${esc(pageName)}`); return `\x00LINK${idx}\x00`; }); } @@ -278,27 +465,43 @@ function markdownToHtml( return `${content}`; }); - // ── Step 11: unordered lists (groups of "- ", "* ", or "+ " lines) ── - html = html.replace(/((?:^[ \t]*[-*+] .+(?:\n|$))+)/gm, (block) => { - const items = block.trim().split("\n") - .map((line) => { const m = line.match(/^[ \t]*[-*+] (.+)$/); return m ? `
            • ${m[1]}
            • ` : ""; }) - .filter(Boolean).join(""); - return `
                ${items}
              `; - }); + // ── Step 10b: horizontal rules (---, ***, ___) ── + html = html.replace(/^[ \t]*([-_*])(?:[ \t]*\1){2,}[ \t]*$/gm, `
              `); - // ── Step 12: ordered lists (groups of "1. " lines) ── - html = html.replace(/((?:^[ \t]*\d+\. .+(?:\n|$))+)/gm, (block) => { - const items = block.trim().split("\n") - .map((line) => { const m = line.match(/^[ \t]*\d+\. (.+)$/); return m ? `
            • ${m[1]}
            • ` : ""; }) - .filter(Boolean).join(""); - return `
                ${items}
              `; - }); + // ── Step 11: lists (unordered + ordered, with nesting and lenient blank lines) ── + // A list block is a run of list-marker lines, optionally separated by single + // blank lines. We capture and process both bullet and numbered markers in one + // pass so the two list styles can nest inside each other. + html = html.replace( + /(?:^[ \t]*(?:[-*+]|\d+\.) .+\n?(?:(?:[ \t]*\n)?(?:[ \t]*(?:[-*+]|\d+\.) .+\n?|[ \t]+\S.*\n?))*)/gm, + (block) => buildNestedList(block), + ); // ── Step 13: blockquotes (">" becomes ">" after escaping) ── - html = html.replace(/((?:^> .+(?:\n|$))+)/gm, (block) => { - const content = block.trim().split("\n") - .map((line) => line.replace(/^> /, "")).join("
              "); - return `
              ${content}
              `; + // Tolerates any non-space characters immediately after ">" (e.g. ">." + // typo lines) so a stray malformed quote line doesn't split a multi-line + // block into separate
              elements. + html = html.replace(/((?:^[ \t]*>.*(?:\n|$))+)/gm, (block) => { + const lines = block.replace(/\n+$/, "").split("\n") + .map((line) => line.replace(/^[ \t]*>[ \t.]*/, "")); + // GFM admonition: first line is "[!TYPE]" or "[TYPE]" (the leading "!" + // is the GFM spec but the corpus often omits it). Match case-insensitively + // and ignore surrounding whitespace. + const first = lines[0]?.trim() ?? ""; + const adm = first.match(/^\[!?(NOTE|TIP|IMPORTANT|WARNING|CAUTION)\]$/i); + if (adm) { + const type = adm[1].toLowerCase(); + const label = type.charAt(0).toUpperCase() + type.slice(1); + const body = lines.slice(1).filter((l) => l.length > 0).join("
              "); + return `
              ` + + `
              ` + + `` + + `${label}` + + `
              ` + + `
              ${body}
              ` + + `
              `; + } + return `
              ${lines.join("
              ")}
              `; }); // ── Step 14: paragraphs (blank lines separate blocks) ── @@ -306,8 +509,9 @@ function markdownToHtml( html = `

              ${html}

              `; html = html.replace(/

              \s*<\/p>/g, ""); // Unwrap block elements incorrectly wrapped in

              - html = html.replace(/

              (\s*<(?:h[1-6]|pre|ul|ol|blockquote)[ >])/g, "$1"); - html = html.replace(/(<\/(?:h[1-6]|pre|ul|ol|blockquote)>\s*)<\/p>/g, "$1"); + html = html.replace(/

              (\s*<(?:h[1-6]|pre|ul|ol|blockquote|hr|div|iframe|svg)[ >\/])/g, "$1"); + html = html.replace(/(<\/(?:h[1-6]|pre|ul|ol|blockquote|div|iframe|svg)>\s*)<\/p>/g, "$1"); + html = html.replace(/(]*>\s*)<\/p>/g, "$1"); // ── Step 15: single newlines →
              (within paragraphs) ── html = html.replace(/\n/g, "
              "); @@ -387,6 +591,7 @@ function linkifyContent( text: string, lookup: (externalId: string) => string | undefined, projectId?: number, + titleLookup?: (externalId: string) => string | undefined, ): string { // Split on code fences and inline code to avoid corrupting code samples. const codeSkip = /(```[\s\S]*?```|`[^`\n]+`)/g; @@ -394,11 +599,11 @@ function linkifyContent( let last = 0; let m: RegExpExecArray | null; while ((m = codeSkip.exec(text)) !== null) { - parts.push(linkifySegment(text.slice(last, m.index), lookup, projectId)); + parts.push(linkifySegment(text.slice(last, m.index), lookup, projectId, titleLookup)); parts.push(m[0]); last = m.index + m[0].length; } - parts.push(linkifySegment(text.slice(last), lookup, projectId)); + parts.push(linkifySegment(text.slice(last), lookup, projectId, titleLookup)); return parts.join(""); } @@ -410,6 +615,7 @@ function linkifySegment( text: string, lookup: (externalId: string) => string | undefined, projectId?: number, + titleLookup?: (externalId: string) => string | undefined, ): string { // Step 1: Resolve cross-project page links [[/project-name/page-name]] text = text.replace(/\[\[\/(.+?)\/([\w-]+)\]\]/g, (_match, projName: string, pageName: string) => { @@ -421,9 +627,11 @@ function linkifySegment( // Only match bare [[word-chars]] that haven't been resolved yet (no | inside). text = text.replace(/\[\[([\w-]+)\]\]/g, (match, pageName: string) => { if (!projectId) return match; - const path = lookup(`page:${projectId}:${pageName}`); + const externalId = `page:${projectId}:${pageName}`; + const path = lookup(externalId); if (!path) return match; - return `[[${path}|${pageName}]]`; + const label = titleLookup?.(externalId) || pageName; + return `[[${path}|${label}]]`; }); // Step 3: Linkify #issue refs and @mentions, skipping inside [[...]] blocks @@ -922,12 +1130,22 @@ export class MantisHubTheme implements Theme { const d = context.entity.data; const source = this.getFilePath(context); const lookup = context.lookupEntityPath ?? (() => undefined); + const titleLookup = context.lookupEntityTitle; const projectId = (d.project as { id: number } | null)?.id; const project = d.project as { id: number; name: string } | null; const createdBy = d.createdBy as { name: string; real_name?: string } | null; const updatedBy = d.updatedBy as { name: string; real_name?: string } | null; - const content = (d.content as string) ?? ""; + let content = (d.content as string) ?? ""; + let pageHeading = (d.title as string) || (d.name as string); + + // If the content begins with a leading H1, prefer it over the stored title + // so we don't render two stacked headers. + const leadingH1 = content.match(/^\s*#\s+(.+?)\s*(?:\n|$)/); + if (leadingH1) { + pageHeading = leadingH1[1]; + content = content.slice(leadingH1[0].length); + } const fm: Record = { title: d.title || d.name, @@ -945,11 +1163,11 @@ export class MantisHubTheme implements Theme { const crumbs = [project?.name, d.name as string].filter(Boolean).join(" > "); sections.push(`**${crumbs}**`); - sections.push(`## ${d.title || d.name}`); + sections.push(`## ${pageHeading}`); // Page content (raw markdown) if (content) { - sections.push(linkifyContent(content, lookup, projectId)); + sections.push(linkifyContent(content, lookup, projectId, titleLookup)); } // File attachments @@ -987,12 +1205,23 @@ export class MantisHubTheme implements Theme { private renderPageHtml(context: ThemeRenderContext): string { const d = context.entity.data; const lookup = context.lookupEntityPath; + const titleLookup = context.lookupEntityTitle; const projectId = (d.project as { id: number } | null)?.id; const project = d.project as { id: number; name: string } | null; const createdBy = d.createdBy as { name: string; real_name?: string } | null; const updatedBy = d.updatedBy as { name: string; real_name?: string } | null; - const content = (d.content as string) ?? ""; + let content = (d.content as string) ?? ""; + const storedTitle = (d.title as string) || (d.name as string); + + // If the content begins with a leading H1, prefer it over the stored title + // to avoid rendering a duplicate header. + let headerTitle = storedTitle; + const leadingH1 = content.match(/^\s*#\s+(.+?)\s*(?:\n|$)/); + if (leadingH1) { + headerTitle = leadingH1[1]; + content = content.slice(leadingH1[0].length); + } const parts: string[] = []; parts.push(`

              `); @@ -1003,7 +1232,7 @@ export class MantisHubTheme implements Theme { const projectHtml = mtProjectLink(project, lookup); parts.push(`
              ${projectHtml} › ${esc(d.name as string)}
              `); } - parts.push(`

              ${esc((d.title as string) || (d.name as string))}

              `); + parts.push(`

              ${esc(headerTitle)}

              `); parts.push(`
              `); // Two-column layout @@ -1016,7 +1245,7 @@ export class MantisHubTheme implements Theme { if (content) { parts.push(`
              `); parts.push(`
              `); - parts.push(`
              ${markdownToHtml(content, lookup, projectId)}
              `); + parts.push(`
              ${markdownToHtml(content, lookup, projectId, titleLookup)}
              `); parts.push(`
              `); parts.push(`
              `); } diff --git a/packages/ui/src/App.css b/packages/ui/src/App.css index 3bcfd87..1a3d031 100644 --- a/packages/ui/src/App.css +++ b/packages/ui/src/App.css @@ -1049,41 +1049,54 @@ body { margin: 24px 0; } -/* ===== Callouts ===== */ +/* ===== Callouts (GitHub-style admonitions) ===== + Each type exposes its colour as --callout-color; the border, title text + and icon all pick that hue, and the background is a soft tint via + color-mix so the body text stays readable. */ .callout { + --callout-color: #0969da; border-radius: var(--radius); padding: 12px 16px; - margin-bottom: 16px; - border-left: 4px solid; + margin: 16px 0; + border-left: 4px solid var(--callout-color); + background: color-mix(in srgb, var(--callout-color) 6%, transparent); } -.callout-info { background: var(--callout-info); border-color: var(--callout-info-border); } -.callout-warning { background: var(--callout-warning); border-color: var(--callout-warning-border); } -.callout-danger { background: var(--callout-danger); border-color: var(--callout-danger-border); } -.callout-tip { background: var(--callout-tip); border-color: var(--callout-tip-border); } -.callout-note { background: var(--callout-note); border-color: var(--callout-note-border); } -.callout-link { background: var(--callout-link); border-color: var(--callout-link-border); } -.callout-git { background: var(--callout-git); border-color: var(--callout-git-border); } +.callout-note, +.callout-info { --callout-color: #0969da; } +.callout-tip { --callout-color: #1a7f37; } +.callout-important { --callout-color: #8250df; } +.callout-warning { --callout-color: #9a6700; } +.callout-caution, +.callout-danger { --callout-color: #cf222e; } +.callout-link { --callout-color: #6e7781; } +.callout-git { --callout-color: #8250df; } .callout-title { - font-weight: 600; - margin-bottom: 4px; display: flex; align-items: center; - gap: 6px; + gap: 8px; + font-weight: 600; + color: var(--callout-color); + margin-bottom: 4px; + line-height: 1; } .callout-icon { - font-size: 16px; + display: inline-flex; + align-items: center; } +.callout-icon svg { display: block; } .callout-body { font-size: 14px; + color: var(--text); + line-height: 1.5; } -.callout-body p:last-child { - margin-bottom: 0; -} +.callout-body :first-child { margin-top: 0; } +.callout-body p:last-child { margin-bottom: 0; } +.callout-body :last-child { margin-bottom: 0; } /* ===== Backlinks Panel (right sidebar) ===== */ .backlinks-panel { @@ -2518,6 +2531,138 @@ body { text-decoration: underline; } +/* Wiki page links — missing target rendered dimmed with dashed underline */ +.mt-page-missing { + color: var(--text-secondary, #888); + text-decoration: underline dashed; + text-underline-offset: 2px; + cursor: help; +} + +/* Markdown content rendered inside MantisHub HTML descriptions. + The global "* { margin: 0; padding: 0 }" reset strips spacing and + list indentation, so we restore them explicitly here. */ +.mt-description p { margin: 8px 0; } +.mt-description p:first-child { margin-top: 0; } +.mt-description p:last-child { margin-bottom: 0; } + +.mt-description ul, +.mt-description ol { + padding-left: 1.6em; + margin: 6px 0; +} +.mt-description ul { list-style: disc outside; } +.mt-description ol { list-style: decimal outside; } +.mt-description ul ul { list-style: circle outside; } +.mt-description ul ul ul { list-style: square outside; } +.mt-description li { margin: 2px 0; } +.mt-description li > ul, +.mt-description li > ol { margin: 2px 0; } + +.mt-description img.mt-md-image { + max-width: 100%; + height: auto; + border-radius: 4px; + margin: 8px 0; +} +.mt-description hr.mt-md-hr { + border: 0; + border-top: 1px solid var(--border, #d0d7de); + margin: 16px 0; +} +.mt-description iframe.mt-md-video, +.markdown-view iframe.mt-md-video { + width: 100%; + aspect-ratio: 16 / 9; + border: 0; + border-radius: 4px; + margin: 8px 0; +} + +/* Komodo play card — used when iframe embedding isn't available */ +.mt-md-video-link { + display: inline-flex; + align-items: center; + gap: 10px; + padding: 10px 16px; + margin: 8px 0; + background: var(--bg-secondary, #f6f8fa); + border: 1px solid var(--border, #d0d7de); + border-radius: 6px; + color: var(--text); + text-decoration: none; + font-weight: 500; +} +.mt-md-video-link:hover { + background: var(--hover-bg, #f0f3f6); + border-color: var(--accent, #0969da); +} +.mt-md-video-icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + border-radius: 50%; + background: var(--accent, #0969da); + color: #fff; + font-size: 12px; +} +.mt-md-video-label { color: inherit; } + +/* GFM admonition callouts (> [!NOTE], > [!WARNING], etc.) — GitHub-style. + Type colors are exposed as a custom property so the title text, icon, and + left border share the same hue while the body text stays neutral. */ +.mt-md-callout { + --callout-color: #0969da; + border-left: 4px solid var(--callout-color); + background: color-mix(in srgb, var(--callout-color) 6%, transparent); + padding: 12px 16px; + margin: 16px 0; + border-radius: 6px; + color: var(--text); +} +.mt-md-callout-title { + display: flex; + align-items: center; + gap: 8px; + font-weight: 600; + color: var(--callout-color); + margin-bottom: 4px; + line-height: 1; +} +.mt-md-callout-icon { + display: inline-flex; + align-items: center; +} +.mt-md-callout-icon svg { display: block; } +.mt-md-callout-body { color: var(--text); line-height: 1.5; } +.mt-md-callout-body :first-child { margin-top: 0; } +.mt-md-callout-body :last-child { margin-bottom: 0; } + +.mt-md-callout-note { --callout-color: #0969da; } +.mt-md-callout-tip { --callout-color: #1a7f37; } +.mt-md-callout-important { --callout-color: #8250df; } +.mt-md-callout-warning { --callout-color: #9a6700; } +.mt-md-callout-caution { --callout-color: #cf222e; } + +/* GFM task lists ("- [ ]" / "- [x]") */ +.mt-md-task-list { + list-style: none; + padding-left: 1.2em; +} +.mt-md-task { + position: relative; +} +.mt-md-task input[type="checkbox"] { + margin-right: 6px; + vertical-align: middle; +} +.mt-md-task-done { + color: var(--text-secondary, #888); + text-decoration: line-through; +} + /* User profile card */ .mt-user-card { display: flex; diff --git a/packages/ui/src/__tests__/MarkdownView.test.tsx b/packages/ui/src/__tests__/MarkdownView.test.tsx index 5667fb4..1373301 100644 --- a/packages/ui/src/__tests__/MarkdownView.test.tsx +++ b/packages/ui/src/__tests__/MarkdownView.test.tsx @@ -211,4 +211,78 @@ describe("MarkdownView", () => { expect(link).toHaveAttribute("target", "_blank"); expect(link).toHaveAttribute("rel", "noopener noreferrer"); }); + + it("renders GFM > [!NOTE] callouts with an icon and type-specific class", () => { + const { container } = render( + [!NOTE]\n> Heads up — this is informational."} + collection="test" + allFiles={[]} + onWikilinkClick={() => {}} + />, + ); + const callout = container.querySelector(".callout.callout-note") as HTMLElement | null; + expect(callout).not.toBeNull(); + expect(callout!.querySelector(".callout-icon svg")).not.toBeNull(); + expect(callout!.querySelector(".callout-title")?.textContent).toContain("Note"); + expect(callout!.querySelector(".callout-body")?.textContent).toContain("Heads up"); + }); + + it("recognises shorthand [Warning] without the GFM '!' prefix", () => { + const { container } = render( + [Warning]\n> Be careful."} + collection="test" + allFiles={[]} + onWikilinkClick={() => {}} + />, + ); + const callout = container.querySelector(".callout.callout-warning"); + expect(callout).not.toBeNull(); + expect(container.textContent).not.toContain("[Warning]"); + }); + + it("renders YouTube image syntax as an iframe player", () => { + const { container } = render( + {}} + />, + ); + const iframe = container.querySelector("iframe.mt-md-video"); + expect(iframe).toBeInTheDocument(); + expect(iframe).toHaveAttribute("src", "https://www.youtube.com/embed/dQw4w9WgXcQ"); + }); + + it("renders Vimeo image syntax as an iframe player", () => { + const { container } = render( + {}} + />, + ); + const iframe = container.querySelector("iframe.mt-md-video"); + expect(iframe).toHaveAttribute("src", "https://player.vimeo.com/video/76979871"); + }); + + it("renders Komodo recordings as a clickable play card (no iframe)", () => { + const { container } = render( + {}} + />, + ); + expect(container.querySelector("iframe")).toBeNull(); + const link = container.querySelector("a.mt-md-video-link") as HTMLAnchorElement | null; + expect(link).not.toBeNull(); + expect(link!.href).toBe("https://kommodo.ai/recordings/abc123"); + expect(link!.target).toBe("_blank"); + expect(link!.textContent).toContain("How to"); + }); }); diff --git a/packages/ui/src/components/MarkdownView.tsx b/packages/ui/src/components/MarkdownView.tsx index 62a92ad..1610a0f 100644 --- a/packages/ui/src/components/MarkdownView.tsx +++ b/packages/ui/src/components/MarkdownView.tsx @@ -110,22 +110,66 @@ function preprocessMarkdown(raw: string, collection: string, filePath?: string): function parseCallout( text: string, ): { type: string; title: string; body: string } | null { - const match = text.match(/^\[!(\w+)\]\s*(.*)/); + // Accept "[!Type]" (GFM / Obsidian) or "[Type]" (loose shorthand seen in + // some MantisHub pages). Type is lowercased for lookup. + // Use [ \t]* (not \s*) so a newline after the marker doesn't get swallowed + // and the body text accidentally captured as the title. + const match = text.match(/^\[!?(\w+)\][ \t]*(.*)/); if (!match) return null; const lines = text.split("\n"); const body = lines.slice(1).join("\n"); - return { type: match[1], title: match[2], body }; + return { type: match[1].toLowerCase(), title: match[2], body }; } -const CALLOUT_ICONS: Record = { - info: "ℹ️", - warning: "⚠️", - danger: "🚨", - tip: "💡", - note: "📝", - link: "🔗", - git: "🔀", -}; +/** Inline Octicon-style SVG icon for a GFM admonition type. */ +function CalloutIcon({ type }: { type: string }) { + const paths: Record = { + note: "M0 8a8 8 0 1 1 16 0A8 8 0 0 1 0 8Zm8-6.5a6.5 6.5 0 1 0 0 13 6.5 6.5 0 0 0 0-13ZM6.5 7.75A.75.75 0 0 1 7.25 7h1a.75.75 0 0 1 .75.75v2.75h.25a.75.75 0 0 1 0 1.5h-2a.75.75 0 0 1 0-1.5h.25v-2h-.25a.75.75 0 0 1-.75-.75ZM8 6a1 1 0 1 1 0-2 1 1 0 0 1 0 2Z", + tip: "M8 1.5c-2.363 0-4 1.69-4 3.75 0 .984.424 1.625.984 2.304l.214.253c.223.264.47.556.673.848.284.411.537.896.621 1.49a.75.75 0 0 1-1.484.211c-.04-.282-.163-.547-.37-.847a8.456 8.456 0 0 0-.542-.68c-.084-.1-.173-.205-.268-.32C3.201 7.75 2.5 6.766 2.5 5.25 2.5 2.31 4.863 0 8 0s5.5 2.31 5.5 5.25c0 1.516-.701 2.5-1.328 3.259-.095.115-.184.22-.268.319-.207.247-.383.453-.541.681-.208.3-.33.565-.37.847a.751.751 0 0 1-1.485-.212c.084-.593.337-1.078.621-1.489.203-.292.45-.584.673-.848.075-.088.147-.173.213-.253.561-.679.985-1.32.985-2.304 0-2.06-1.637-3.75-4-3.75ZM5.75 12h4.5a.75.75 0 0 1 0 1.5h-4.5a.75.75 0 0 1 0-1.5ZM6 15.25a.75.75 0 0 1 .75-.75h2.5a.75.75 0 0 1 0 1.5h-2.5a.75.75 0 0 1-.75-.75Z", + important: "M0 1.75C0 .784.784 0 1.75 0h12.5C15.216 0 16 .784 16 1.75v9.5A1.75 1.75 0 0 1 14.25 13H8.06l-2.573 2.573A1.458 1.458 0 0 1 3 14.543V13H1.75A1.75 1.75 0 0 1 0 11.25Zm1.75-.25a.25.25 0 0 0-.25.25v9.5c0 .138.112.25.25.25h2a.75.75 0 0 1 .75.75v2.19l2.72-2.72a.749.749 0 0 1 .53-.22h6.5a.25.25 0 0 0 .25-.25v-9.5a.25.25 0 0 0-.25-.25Zm7 2.25v2.5a.75.75 0 0 1-1.5 0v-2.5a.75.75 0 0 1 1.5 0ZM9 9a1 1 0 1 1-2 0 1 1 0 0 1 2 0Z", + warning: "M6.457 1.047c.659-1.234 2.427-1.234 3.086 0l6.082 11.378A1.75 1.75 0 0 1 14.082 15H1.918a1.75 1.75 0 0 1-1.543-2.575Zm1.763.707a.25.25 0 0 0-.44 0L1.698 13.132a.25.25 0 0 0 .22.368h12.164a.25.25 0 0 0 .22-.368Zm.53 3.996v2.5a.75.75 0 0 1-1.5 0v-2.5a.75.75 0 0 1 1.5 0ZM9 11a1 1 0 1 1-2 0 1 1 0 0 1 2 0Z", + caution: "M4.47.22A.749.749 0 0 1 5 0h6c.199 0 .389.079.53.22l4.25 4.25c.141.14.22.331.22.53v6a.749.749 0 0 1-.22.53l-4.25 4.25A.749.749 0 0 1 11 16H5a.749.749 0 0 1-.53-.22L.22 11.53A.749.749 0 0 1 0 11V5c0-.199.079-.389.22-.53Zm.84 1.28L1.5 5.31v5.38l3.81 3.81h5.38l3.81-3.81V5.31L10.69 1.5ZM8 4a.75.75 0 0 1 .75.75v3.5a.75.75 0 0 1-1.5 0v-3.5A.75.75 0 0 1 8 4Zm0 8a1 1 0 1 1 0-2 1 1 0 0 1 0 2Z", + }; + const fallbackEmoji: Record = { + info: "ℹ︎", + danger: "⛔", + link: "🔗", + git: "🔀", + }; + const path = paths[type]; + if (path) { + return ( + + + + ); + } + return {fallbackEmoji[type] ?? "📌"}; +} + +/** + * Detect a video-sharing URL written as a markdown image and return embed info. + * Komodo (kommodo.ai / komododecks.com) is rendered as a clickable play card + * because their iframe-embed URL is a premium feature with no public pattern. + */ +function videoEmbedFromUrl(url: string): { provider: "komodo" | "loom" | "youtube" | "vimeo"; embedUrl: string; openUrl: string } | null { + let m: RegExpMatchArray | null; + if ((m = url.match(/^https?:\/\/(?:www\.)?(?:komododecks\.com|kommodo\.ai)\/recordings\/([\w-]+)/))) { + const open = `https://kommodo.ai/recordings/${m[1]}`; + return { provider: "komodo", embedUrl: open, openUrl: open }; + } + if ((m = url.match(/^https?:\/\/(?:www\.)?loom\.com\/share\/([\w-]+)/))) { + return { provider: "loom", embedUrl: `https://www.loom.com/embed/${m[1]}`, openUrl: `https://www.loom.com/share/${m[1]}` }; + } + if ((m = url.match(/^https?:\/\/(?:www\.)?youtube\.com\/watch\?v=([\w-]+)/)) || + (m = url.match(/^https?:\/\/youtu\.be\/([\w-]+)/))) { + return { provider: "youtube", embedUrl: `https://www.youtube.com/embed/${m[1]}`, openUrl: `https://www.youtube.com/watch?v=${m[1]}` }; + } + if ((m = url.match(/^https?:\/\/(?:www\.)?vimeo\.com\/(\d+)/))) { + return { provider: "vimeo", embedUrl: `https://player.vimeo.com/video/${m[1]}`, openUrl: `https://vimeo.com/${m[1]}` }; + } + return null; +} function resolveWikilink(target: string, allFiles: string[]): string | null { const directPath = target.endsWith(".md") ? target : `${target}.md`; @@ -157,6 +201,30 @@ export default function MarkdownView({ } return {children}; }, + img({ src, alt }) { + const url = typeof src === "string" ? src : ""; + const video = url ? videoEmbedFromUrl(url) : null; + if (video) { + if (video.provider === "komodo") { + return ( + + ▶ + {alt || "Watch recording"} + + ); + } + return ( +