From f273b70734d3c34ffb87691f10e95942884d568c Mon Sep 17 00:00:00 2001 From: restuta Date: Tue, 24 Mar 2026 13:42:39 +0700 Subject: [PATCH 1/3] feat: resolve Excalidraw embeds to exports --- src/core/markdown.ts | 9 +++- src/core/publish-markdown.ts | 44 ++++++++++++++++++- tests/integration/cli.test.ts | 66 +++++++++++++++++++++++++++++ tests/unit/publish-markdown.test.ts | 31 ++++++++++++++ 4 files changed, 147 insertions(+), 3 deletions(-) diff --git a/src/core/markdown.ts b/src/core/markdown.ts index 156d652..e5bce5e 100644 --- a/src/core/markdown.ts +++ b/src/core/markdown.ts @@ -52,11 +52,18 @@ export async function renderMarkdownToHtml( markdown: string, ): Promise { const renderedMarkdown = autolinkBareUrls(stripWikilinks(markdown.trim())); + const defaultProtocols = defaultSchema.protocols as + | Record | null | undefined> + | undefined; + const defaultSrcProtocolsValue = defaultProtocols?.["src"]; + const defaultSrcProtocols = Array.isArray(defaultSrcProtocolsValue) + ? defaultSrcProtocolsValue + : []; const sanitizeSchema = { ...defaultSchema, protocols: { ...defaultSchema.protocols, - src: [...(defaultSchema.protocols?.["src"] ?? []), "data"], + src: [...defaultSrcProtocols, "data"], }, }; const rawHtml = String( diff --git a/src/core/publish-markdown.ts b/src/core/publish-markdown.ts index 6f06b19..a1dbdb7 100644 --- a/src/core/publish-markdown.ts +++ b/src/core/publish-markdown.ts @@ -14,6 +14,8 @@ const LOCAL_IMAGE_EXTENSIONS = new Set([ ".svg", ".avif", ]); +const EXCALIDRAW_MARKDOWN_SUFFIX = ".excalidraw.md"; +const EXCALIDRAW_EXPORT_EXTENSIONS = [".svg", ".png", ".webp", ".jpg", ".jpeg"]; const OBSIDIAN_IMAGE_EMBED_RE = /!\[\[([^\]\n]+)\]\]/g; const MARKDOWN_IMAGE_RE = /!\[([^\]]*)\]\(([^)\n]+)\)/g; @@ -161,7 +163,11 @@ async function resolveLocalImageAsset( baseDir: string, rawTarget: string, ): Promise<{ alt: string; dataUrl: string } | null> { - const resolvedPath = path.resolve(baseDir, rawTarget); + const excalidrawExportPath = await resolveExcalidrawExportPath( + baseDir, + rawTarget, + ); + const resolvedPath = excalidrawExportPath ?? path.resolve(baseDir, rawTarget); const extension = path.extname(resolvedPath).toLowerCase(); if (!LOCAL_IMAGE_EXTENSIONS.has(extension)) { @@ -171,7 +177,13 @@ async function resolveLocalImageAsset( try { const content = await readFile(resolvedPath); return { - alt: escapeMarkdownLabel(path.basename(rawTarget)), + alt: escapeMarkdownLabel( + path.basename( + excalidrawExportPath === null + ? rawTarget + : rawTarget.replace(/\.excalidraw\.md$/i, extension), + ), + ), dataUrl: buildDataUrl(content, extension), }; } catch { @@ -179,6 +191,34 @@ async function resolveLocalImageAsset( } } +async function resolveExcalidrawExportPath( + baseDir: string, + rawTarget: string, +): Promise { + if (!rawTarget.toLowerCase().endsWith(EXCALIDRAW_MARKDOWN_SUFFIX)) { + return null; + } + + const absoluteTarget = path.resolve(baseDir, rawTarget); + const exportBasePath = absoluteTarget.slice( + 0, + -EXCALIDRAW_MARKDOWN_SUFFIX.length, + ); + + for (const extension of EXCALIDRAW_EXPORT_EXTENSIONS) { + const candidatePath = `${exportBasePath}${extension}`; + + try { + await readFile(candidatePath); + return candidatePath; + } catch { + // Try the next export format. + } + } + + return null; +} + function buildDataUrl(content: Buffer, extension: string): string { return `data:${mimeTypeForExtension(extension)};base64,${content.toString("base64")}`; } diff --git a/tests/integration/cli.test.ts b/tests/integration/cli.test.ts index c6c534d..27210a6 100644 --- a/tests/integration/cli.test.ts +++ b/tests/integration/cli.test.ts @@ -166,6 +166,72 @@ Look: const rawMarkdown = await rawResponse.text(); expect(rawMarkdown).toContain("![[diagram.svg|320x200]]"); }); + + it("renders Excalidraw embeds from sibling exported images while preserving the raw note", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "publish-it-cli-excal-")); + const configDir = path.join(root, "config"); + const mappingPath = path.join(root, ".pub"); + const cwd = path.join(root, "workspace"); + const notePath = path.join(cwd, "note.md"); + const drawingPath = path.join(cwd, "landscape.excalidraw.md"); + const exportPath = path.join(cwd, "landscape.svg"); + + server = await startTestServer(root); + await mkdir(cwd, { recursive: true }); + await writeFile( + drawingPath, + `--- +excalidraw-plugin: parsed +--- +`, + "utf8", + ); + await writeFile( + exportPath, + '', + "utf8", + ); + await writeFile( + notePath, + `--- +title: Excalidraw Embed +--- + +Diagram: + +![[landscape.excalidraw.md]] +`, + "utf8", + ); + + await runCli(["claim", "restuta", "--api-base", server.origin], { + cwd, + env: { + PUB_CONFIG_DIR: configDir, + PUB_MAPPING_PATH: mappingPath, + }, + }); + + const publishResult = await runCli( + ["publish", notePath, "--api-base", server.origin], + { + cwd, + env: { + PUB_CONFIG_DIR: configDir, + PUB_MAPPING_PATH: mappingPath, + }, + }, + ); + const pageUrl = publishResult.stdout.trim(); + + const htmlResponse = await fetch(pageUrl); + const html = await htmlResponse.text(); + expect(html).toContain("data:image/svg+xml;base64,"); + + const rawResponse = await fetch(`${pageUrl}?raw=1`); + const rawMarkdown = await rawResponse.text(); + expect(rawMarkdown).toContain("![[landscape.excalidraw.md]]"); + }); }); async function runCli( diff --git a/tests/unit/publish-markdown.test.ts b/tests/unit/publish-markdown.test.ts index df2b6db..cbfedd8 100644 --- a/tests/unit/publish-markdown.test.ts +++ b/tests/unit/publish-markdown.test.ts @@ -50,4 +50,35 @@ describe("prepareMarkdownBodyForPublish", () => { expect(prepared).toContain("![Team photo](data:image/svg+xml;base64,"); }); + + it("resolves Excalidraw embeds to sibling exported images", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "pubmd-excalidraw-")); + const notePath = path.join(root, "note.md"); + const drawingPath = path.join(root, "diagram.excalidraw.md"); + const exportPath = path.join(root, "diagram.svg"); + + await writeFile( + drawingPath, + `--- +excalidraw-plugin: parsed +--- +`, + "utf8", + ); + await writeFile( + exportPath, + '', + "utf8", + ); + + const prepared = await prepareMarkdownBodyForPublish( + "Here:\n\n![[diagram.excalidraw.md]]", + { + sourcePath: notePath, + }, + ); + + expect(prepared).toContain("data:image/svg+xml;base64,"); + expect(prepared).not.toContain("![[diagram.excalidraw.md]]"); + }); }); From c7be71cf9434cebe4111e5809f3d99be2b012a5f Mon Sep 17 00:00:00 2001 From: restuta Date: Sat, 28 Mar 2026 17:55:23 +0700 Subject: [PATCH 2/3] fix: sync toc active state on click --- src/core/markdown.ts | 61 ++++++++++++++++++++++++++++++++++--- tests/unit/markdown.test.ts | 45 +++++++++++++++++++++++++++ 2 files changed, 102 insertions(+), 4 deletions(-) diff --git a/src/core/markdown.ts b/src/core/markdown.ts index e5bce5e..b9e78c3 100644 --- a/src/core/markdown.ts +++ b/src/core/markdown.ts @@ -28,6 +28,35 @@ export interface RenderedPageDocument { renderedMarkdown: string; } +export interface HeadingPosition { + id: string; + top: number; +} + +export const TOC_ACTIVE_OFFSET_PX = 120; + +export function getActiveHeadingId( + headings: ReadonlyArray, + activationOffset = TOC_ACTIVE_OFFSET_PX, +): string { + if (headings.length === 0) { + return ""; + } + + let activeId = headings[0]?.id ?? ""; + + for (const heading of headings) { + if (heading.top <= activationOffset) { + activeId = heading.id; + continue; + } + + break; + } + + return activeId; +} + export function parseMarkdownDocument( markdown: string, ): ParsedMarkdownDocument { @@ -477,14 +506,38 @@ export function buildHtmlDocument(input: { wrap.appendChild(nav); document.body.appendChild(wrap); let activeId = ''; - const obs = new IntersectionObserver(entries => { - entries.forEach(e => { if (e.isIntersecting) activeId = e.target.id; }); + let syncFrame = 0; + const setActive = id => { + if (!id || id === activeId) return; + activeId = id; lineEls.forEach(l => l.classList.toggle('active', l.dataset.id === activeId)); nav.querySelectorAll('a').forEach(a => { a.classList.toggle('active', a.getAttribute('href') === '#' + activeId); }); - }, { rootMargin: '-10% 0px -80% 0px' }); - headings.forEach(h => obs.observe(h)); + }; + const syncActiveHeading = () => { + syncFrame = 0; + let nextActiveId = headings[0]?.id ?? ''; + headings.forEach(h => { + if (h.getBoundingClientRect().top <= ${TOC_ACTIVE_OFFSET_PX}) { + nextActiveId = h.id; + } + }); + setActive(nextActiveId); + }; + const queueActiveSync = () => { + if (syncFrame !== 0) return; + syncFrame = window.requestAnimationFrame(syncActiveHeading); + }; + nav.querySelectorAll('a').forEach(a => { + a.addEventListener('click', () => { + const targetId = a.getAttribute('href')?.slice(1); + if (targetId) setActive(targetId); + }); + }); + window.addEventListener('scroll', queueActiveSync, { passive: true }); + window.addEventListener('resize', queueActiveSync); + queueActiveSync(); } diff --git a/tests/unit/markdown.test.ts b/tests/unit/markdown.test.ts index 669bee9..f188df7 100644 --- a/tests/unit/markdown.test.ts +++ b/tests/unit/markdown.test.ts @@ -3,8 +3,10 @@ import { describe, expect, it } from "vitest"; import { autolinkBareUrls, buildHtmlDocument, + getActiveHeadingId, parseMarkdownDocument, renderMarkdownToHtml, + TOC_ACTIVE_OFFSET_PX, } from "../../src/core/markdown.js"; describe("markdown pipeline", () => { @@ -48,6 +50,8 @@ const answer = 42; expect(html).toContain('rel="icon"'); expect(html).toContain("--link:"); expect(html).toContain("text-underline-offset"); + expect(html).toContain("const setActive = id =>"); + expect(html).toContain("if (targetId) setActive(targetId);"); }); it("renders real-world mixed markdown structures cleanly", async () => { @@ -90,6 +94,47 @@ Stores raw .md + pre-rendered .html }); }); +describe("getActiveHeadingId", () => { + it("returns an empty id when there are no headings", () => { + expect(getActiveHeadingId([])).toBe(""); + }); + + it("uses the first heading before the scroll threshold is reached", () => { + expect( + getActiveHeadingId([ + { id: "intro", top: 180 }, + { id: "details", top: 420 }, + ]), + ).toBe("intro"); + }); + + it("uses the last heading that has crossed the active offset", () => { + expect( + getActiveHeadingId( + [ + { id: "intro", top: -240 }, + { id: "details", top: 80 }, + { id: "faq", top: 280 }, + ], + TOC_ACTIVE_OFFSET_PX, + ), + ).toBe("details"); + }); + + it("promotes a clicked destination once it reaches the viewport threshold", () => { + expect( + getActiveHeadingId( + [ + { id: "intro", top: -320 }, + { id: "details", top: -40 }, + { id: "faq", top: 40 }, + ], + TOC_ACTIVE_OFFSET_PX, + ), + ).toBe("faq"); + }); +}); + describe("autolinkBareUrls", () => { it("links bare domain URLs", () => { expect(autolinkBareUrls("check github.com/foo/bar for details")).toBe( From 61f7269a4b43513f16e68db0f659e9c690411186 Mon Sep 17 00:00:00 2001 From: restuta Date: Sat, 28 Mar 2026 18:04:10 +0700 Subject: [PATCH 3/3] fix: restore adaptive toc headings --- src/core/markdown.ts | 66 ++++++++++++++++++++++++++++++------- tests/unit/markdown.test.ts | 33 +++++++++++++++++++ 2 files changed, 87 insertions(+), 12 deletions(-) diff --git a/src/core/markdown.ts b/src/core/markdown.ts index b9e78c3..62fe48a 100644 --- a/src/core/markdown.ts +++ b/src/core/markdown.ts @@ -38,11 +38,16 @@ export const TOC_ACTIVE_OFFSET_PX = 120; export function getActiveHeadingId( headings: ReadonlyArray, activationOffset = TOC_ACTIVE_OFFSET_PX, + isNearPageEnd = false, ): string { if (headings.length === 0) { return ""; } + if (isNearPageEnd) { + return headings.at(-1)?.id ?? ""; + } + let activeId = headings[0]?.id ?? ""; for (const heading of headings) { @@ -403,8 +408,8 @@ export function buildHtmlDocument(input: { border-radius: 1px; transition: background 150ms ease; } - .toc-lines span.depth-2 { width: 24px; } - .toc-lines span.depth-3 { width: 14px; } + .toc-lines span.depth-root { width: 24px; } + .toc-lines span.depth-child { width: 14px; } .toc-lines span.active { background: var(--fg); } .toc-nav { position: absolute; @@ -437,7 +442,7 @@ export function buildHtmlDocument(input: { } .toc-nav a:hover { color: var(--fg); background: var(--surface); } .toc-nav a.active { color: var(--link); background: var(--surface); } - .toc-nav a.depth-3 { padding-left: 1.75rem; font-size: 0.8rem; } + .toc-nav a.depth-child { padding-left: 1.75rem; font-size: 0.8rem; } /* Mobile adjustments */ @media (max-width: 600px) { @@ -475,7 +480,33 @@ export function buildHtmlDocument(input: { }); }); // TOC navigation — minimap lines + hover popover - const headings = document.querySelectorAll('article h2, article h3'); + const pageTitle = ${JSON.stringify(input.title)}; + const bodyHeadings = Array.from( + document.querySelectorAll('article h1, article h2, article h3, article h4'), + ).filter((heading, index) => { + if (index !== 0) { + return true; + } + + return !( + heading.tagName === 'H1' && + (heading.textContent || '').trim() === pageTitle + ); + }); + const headingLevels = bodyHeadings.map((heading) => + Number(heading.tagName.slice(1)), + ); + const rootLevel = + headingLevels.length > 0 ? Math.min(...headingLevels) : null; + const childLevel = + rootLevel !== null && headingLevels.includes(rootLevel + 1) + ? rootLevel + 1 + : null; + const headings = bodyHeadings.filter((heading) => { + const level = Number(heading.tagName.slice(1)); + return level === rootLevel || level === childLevel; + }); + if (headings.length >= 3) { headings.forEach((h, i) => { if (!h.id) h.id = 'h-' + i; }); const wrap = document.createElement('div'); @@ -487,7 +518,11 @@ export function buildHtmlDocument(input: { const lineEls = []; headings.forEach(h => { const line = document.createElement('span'); - const depth = h.tagName === 'H3' ? 'depth-3' : 'depth-2'; + const depth = + childLevel !== null && + Number(h.tagName.slice(1)) === childLevel + ? 'depth-child' + : 'depth-root'; line.className = depth; line.dataset.id = h.id; lines.appendChild(line); @@ -495,7 +530,7 @@ export function buildHtmlDocument(input: { const a = document.createElement('a'); a.href = '#' + h.id; a.textContent = h.textContent; - if (h.tagName === 'H3') a.classList.add('depth-3'); + if (depth === 'depth-child') a.classList.add('depth-child'); a.addEventListener('click', e => { e.preventDefault(); h.scrollIntoView({ behavior: 'auto', block: 'start' }); @@ -517,12 +552,19 @@ export function buildHtmlDocument(input: { }; const syncActiveHeading = () => { syncFrame = 0; - let nextActiveId = headings[0]?.id ?? ''; - headings.forEach(h => { - if (h.getBoundingClientRect().top <= ${TOC_ACTIVE_OFFSET_PX}) { - nextActiveId = h.id; - } - }); + const isNearPageEnd = + window.innerHeight + window.scrollY >= + document.documentElement.scrollHeight - 4; + let nextActiveId = isNearPageEnd + ? headings.at(-1)?.id ?? '' + : headings[0]?.id ?? ''; + if (!isNearPageEnd) { + headings.forEach(h => { + if (h.getBoundingClientRect().top <= ${TOC_ACTIVE_OFFSET_PX}) { + nextActiveId = h.id; + } + }); + } setActive(nextActiveId); }; const queueActiveSync = () => { diff --git a/tests/unit/markdown.test.ts b/tests/unit/markdown.test.ts index f188df7..b677113 100644 --- a/tests/unit/markdown.test.ts +++ b/tests/unit/markdown.test.ts @@ -50,10 +50,29 @@ const answer = 42; expect(html).toContain('rel="icon"'); expect(html).toContain("--link:"); expect(html).toContain("text-underline-offset"); + expect(html).toContain('const pageTitle = "Demo";'); + expect(html).toContain("article h1, article h2, article h3, article h4"); + expect(html).toContain("depth-root"); + expect(html).toContain("depth-child"); expect(html).toContain("const setActive = id =>"); expect(html).toContain("if (targetId) setActive(targetId);"); }); + it("builds adaptive TOC logic for documents that use body h1 headings", () => { + const html = buildHtmlDocument({ + title: "Doc Title", + description: "Example", + noindex: true, + bodyHtml: + "

Doc Title

Section

Child

Another

", + }); + + expect(html).toContain('const pageTitle = "Doc Title";'); + expect(html).toContain("article h1, article h2, article h3, article h4"); + expect(html).toContain("depth-root"); + expect(html).toContain("depth-child"); + }); + it("renders real-world mixed markdown structures cleanly", async () => { const rendered = await renderMarkdownToHtml(` # Publish-It — Project Plan @@ -133,6 +152,20 @@ describe("getActiveHeadingId", () => { ), ).toBe("faq"); }); + + it("keeps the last heading active near the end of the page", () => { + expect( + getActiveHeadingId( + [ + { id: "section-one", top: -300 }, + { id: "child-one", top: -120 }, + { id: "section-two", top: 600 }, + ], + TOC_ACTIVE_OFFSET_PX, + true, + ), + ).toBe("section-two"); + }); }); describe("autolinkBareUrls", () => {