diff --git a/AGENTS.md b/AGENTS.md index faae68b8..a29521b7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -217,7 +217,7 @@ OpenCode/Pi: event handler intercepts command ↓ Input type detected: .md/.mdx → file read from disk - .html/.htm → file read, converted to markdown via Turndown (or rendered as-is with --render-html) + .html/.htm → file read, rendered as raw HTML by default (or converted to markdown with --markdown) https:// → fetched via Jina Reader (default) or fetch+Turndown (--no-jina) folder/ → file browser opened, files converted on demand ↓ @@ -330,6 +330,8 @@ During normal plan review, an Archive sidebar tab provides the same browsing via | `/api/feedback` | POST | Submit annotations (body: feedback, annotations) | | `/api/approve` | POST | Approve without feedback (review-gate UX, `--gate`) | | `/api/exit` | POST | Close session without feedback | +| `/api/html-assets//` | GET | Serve relative support assets for raw HTML annotation sessions | +| `/api/share-html` | GET | Lazily prepare portable raw HTML for sharing (`?path=` optional) | | `/api/image` | GET | Serve image by path query param | | `/api/upload` | POST | Upload image, returns `{ path, originalName }` | | `/api/doc` | GET | Serve linked .md/.mdx/.html file or code file (`?path=&base=`) | @@ -486,7 +488,7 @@ interface SharePayload { g?: ShareableImage[]; // Global attachments d?: (string | null)[]; // diffContext per annotation, parallel to `a` s?: (string | undefined)[]; // source per annotation (external tool identifier), parallel to `a` - h?: string; // Raw HTML content (--render-html mode) + h?: string; // Raw HTML content (direct HTML rendering mode) r?: 'html'; // Render mode flag (omitted = markdown) } diff --git a/apps/amp-plugin/plannotator.test.ts b/apps/amp-plugin/plannotator.test.ts index 8b12a5aa..0d4f59f9 100644 --- a/apps/amp-plugin/plannotator.test.ts +++ b/apps/amp-plugin/plannotator.test.ts @@ -91,6 +91,7 @@ describe("Amp Plannotator plugin helpers", () => { test("finds annotate target after flags", () => { expect(findFirstPositionalArg(["--no-jina", "https://example.com"])).toBe("https://example.com"); + expect(findFirstPositionalArg(["--markdown", "docs/page.html"])).toBe("docs/page.html"); expect(findFirstPositionalArg(["--browser", "Google Chrome", "docs/plan.md"])).toBe("docs/plan.md"); }); diff --git a/apps/hook/README.md b/apps/hook/README.md index 437969c7..373e590a 100644 --- a/apps/hook/README.md +++ b/apps/hook/README.md @@ -111,7 +111,7 @@ Plannotator's slash commands are installed as Claude Code skills in `~/.claude/s | Command | Description | |---------|-------------| | `/plannotator-review [--git]` | Open code review UI for current changes or a GitHub PR; `--git` forces Git in JJ workspaces | -| `/plannotator-annotate ` | Annotate any markdown file | +| `/plannotator-annotate ` | Annotate a file, URL, or folder | | `/plannotator-last` | Annotate the agent's last message | ## Obsidian Integration diff --git a/apps/hook/server/cli.test.ts b/apps/hook/server/cli.test.ts index fb51ce33..1f094ef4 100644 --- a/apps/hook/server/cli.test.ts +++ b/apps/hook/server/cli.test.ts @@ -23,6 +23,7 @@ describe("CLI top-level help", () => { expect(output).toContain("plannotator [--browser ]"); expect(output).toContain("plannotator review [--git] [PR_URL]"); expect(output).toContain("plannotator annotate "); + expect(output).toContain("[--markdown] [--no-jina]"); expect(output).toContain("plannotator annotate-last [--stdin]"); expect(output).toContain("plannotator setup-goal "); expect(output).toContain("running 'plannotator' without arguments is for hook integration"); diff --git a/apps/hook/server/cli.ts b/apps/hook/server/cli.ts index e2028705..3ddde914 100644 --- a/apps/hook/server/cli.ts +++ b/apps/hook/server/cli.ts @@ -26,7 +26,7 @@ export function formatTopLevelHelp(): string { " plannotator --version, -v", " plannotator [--browser ]", " plannotator review [--git] [PR_URL]", - " plannotator annotate [--no-jina] [--gate] [--json] [--hook]", + " plannotator annotate [--markdown] [--no-jina] [--gate] [--json] [--hook]", " plannotator annotate-last [--stdin] [--gate] [--json] [--hook]", " plannotator setup-goal [--json]", " plannotator last", diff --git a/apps/hook/server/index.ts b/apps/hook/server/index.ts index e30f6293..33fd0ad0 100644 --- a/apps/hook/server/index.ts +++ b/apps/hook/server/index.ts @@ -109,6 +109,7 @@ import { } from "@plannotator/shared/prompts"; import { registerSession, unregisterSession, listSessions } from "@plannotator/server/sessions"; import { openBrowser } from "@plannotator/server/browser"; +import { inlineHtmlLocalAssets } from "@plannotator/server/html-assets"; import { detectProjectName } from "@plannotator/server/project"; import { hostnameOrFallback } from "@plannotator/shared/project"; import { readImprovementHook } from "@plannotator/shared/improvement-hooks"; @@ -178,8 +179,10 @@ const hookFlag = hookIdx !== -1; if (hookFlag) args.splice(hookIdx, 1); if (hookFlag) gateFlag = true; const renderHtmlIdx = args.indexOf("--render-html"); -const renderHtmlFlag = renderHtmlIdx !== -1; -if (renderHtmlFlag) args.splice(renderHtmlIdx, 1); +if (renderHtmlIdx !== -1) args.splice(renderHtmlIdx, 1); +const renderMarkdownIdx = args.indexOf("--markdown"); +const renderMarkdownFlag = renderMarkdownIdx !== -1; +if (renderMarkdownFlag) args.splice(renderMarkdownIdx, 1); // Stdout matrix for annotate / annotate-last / copilot annotate-last. // @@ -867,7 +870,7 @@ if (args[0] === "sessions") { const rawFilePath = args[1]; if (!rawFilePath) { - console.error("Usage: plannotator annotate [--no-jina] [--gate] [--json] [--hook]"); + console.error("Usage: plannotator annotate [--markdown] [--no-jina] [--gate] [--json] [--hook]"); process.exit(1); } @@ -940,12 +943,9 @@ if (args[0] === "sessions") { if (htmlCandidate !== null) { const resolvedArg = resolveUserPath(htmlCandidate, projectRoot); const htmlFile = Bun.file(resolvedArg); - if (htmlFile.size > 10 * 1024 * 1024) { - console.error(`File too large (${Math.round(htmlFile.size / 1024 / 1024)}MB, max 10MB): ${resolvedArg}`); - process.exit(1); - } const html = await htmlFile.text(); - if (renderHtmlFlag) { + const renderHtmlForFile = !renderMarkdownFlag; + if (renderHtmlForFile) { rawHtml = html; markdown = ""; } else { @@ -954,7 +954,7 @@ if (args[0] === "sessions") { } absolutePath = resolvedArg; sourceInfo = path.basename(resolvedArg); - console.error(`${renderHtmlFlag ? "Raw HTML" : "Converted"}: ${absolutePath}`); + console.error(`${renderHtmlForFile ? "Raw HTML" : "Converted"}: ${absolutePath}`); } else { // Single markdown file annotation mode // Strip-first with literal-@ fallback (scoped-package-style names). @@ -1011,13 +1011,21 @@ if (args[0] === "sessions") { pasteApiUrl, gate: gateFlag, rawHtml, - renderHtml: renderHtmlFlag, + renderHtml: !!rawHtml, + convertHtml: renderMarkdownFlag, htmlContent: planHtmlContent, onReady: async (url, isRemote, port) => { handleAnnotateServerReady(url, isRemote, port); - if (isRemote && sharingEnabled && markdown) { - await writeRemoteShareLink(markdown, shareBaseUrl, "annotate", "document only").catch(() => {}); + if (isRemote && sharingEnabled) { + if (rawHtml) { + await writeRemoteShareLink("", shareBaseUrl, "annotate", "HTML document only", { + rawHtml: inlineHtmlLocalAssets(rawHtml, absolutePath), + pasteApiUrl, + }).catch(() => {}); + } else if (markdown) { + await writeRemoteShareLink(markdown, shareBaseUrl, "annotate", "document only").catch(() => {}); + } } }, }); diff --git a/apps/marketing/src/content/blog/annotate-any-web-page-or-html-file.md b/apps/marketing/src/content/blog/annotate-any-web-page-or-html-file.md index d3b2abfe..4ce849b8 100644 --- a/apps/marketing/src/content/blog/annotate-any-web-page-or-html-file.md +++ b/apps/marketing/src/content/blog/annotate-any-web-page-or-html-file.md @@ -1,12 +1,12 @@ --- title: "Annotate Any Web Page or HTML File" -description: "Plannotator's /plannotator-annotate command now accepts URLs and HTML files, not just markdown. Fetch any web page, convert it to markdown, and annotate it with structured feedback for your coding agent." +description: "Plannotator's /plannotator-annotate command now accepts URLs and HTML files, not just markdown. Fetch any web page, render local HTML, and annotate it with structured feedback for your coding agent." date: 2026-04-12 author: "backnotprop" tags: ["annotate", "url", "html", "jina-reader"] --- -**Plannotator is an open-source review UI for AI coding agents.** The `/plannotator-annotate` command now accepts URLs and HTML files alongside markdown, so you can pull in any external documentation, convert it on the fly, and send structured annotations back to your agent session. +**Plannotator is an open-source review UI for AI coding agents.** The `/plannotator-annotate` command now accepts URLs and HTML files alongside markdown, so you can pull in any external documentation, render local HTML directly, and send structured annotations back to your agent session. @@ -22,12 +22,13 @@ This was a [community-requested feature](https://github.com/backnotprop/plannota plannotator annotate https://docs.stripe.com/api # remote URL plannotator annotate docs/guide.html # local HTML file plannotator annotate ./docs/ # folder (now includes .html files) +plannotator annotate docs/guide.html --markdown # convert local HTML to markdown plannotator annotate https://... --no-jina # direct fetch, no Jina ``` -All inputs are converted to markdown before reaching the annotation editor. You annotate, highlight, comment, and when you click Send Annotations, structured feedback goes back to the agent session. +URLs are converted to markdown before reaching the annotation editor. Local HTML renders as HTML by default, with `--markdown` available when you want the old Turndown conversion path. You annotate, highlight, comment, and when you click Send Annotations, structured feedback goes back to the agent session. -Local HTML files are read from disk and converted via Turndown. Folders now show `.html` and `.htm` files alongside markdown in the file browser, with conversion happening on demand. A source attribution badge appears under the document title so you know what you're looking at. +Folders now show `.html` and `.htm` files alongside markdown in the file browser. A source attribution badge appears under the document title so you know what you're looking at. ## How URL fetching works diff --git a/apps/marketing/src/content/docs/commands/annotate.md b/apps/marketing/src/content/docs/commands/annotate.md index 0f002dfa..497b9cc6 100644 --- a/apps/marketing/src/content/docs/commands/annotate.md +++ b/apps/marketing/src/content/docs/commands/annotate.md @@ -13,7 +13,7 @@ The `/plannotator-annotate` command opens files, URLs, or folders in the Plannot | Input | Command | What happens | |-------|---------|--------------| | Markdown file | `plannotator annotate README.md` | Opens the file directly | -| HTML file | `plannotator annotate docs/guide.html` | Converts to markdown via Turndown, then opens | +| HTML file | `plannotator annotate docs/guide.html` | Renders the HTML directly | | URL | `plannotator annotate https://docs.stripe.com/api` | Fetches the page, converts to markdown, then opens | | Folder | `plannotator annotate ./docs/` | Opens a file browser showing all `.md`, `.mdx`, `.html`, and `.htm` files | @@ -82,10 +82,20 @@ If none of these are set, Jina is enabled by default. ## HTML files -Local `.html` and `.htm` files are read from disk and converted to markdown using [Turndown](https://github.com/mixmark-io/turndown) with GFM table support. ` + + + + + + +`; + + const out = rewrite(html); + + expect(out).toContain('href="/api/html-assets/t/style.css?v=1"'); + expect(out).toContain('href="/api/html-assets/t/icons/app%20icon.png"'); + expect(out).toContain('background: url("/api/html-assets/t/hero.png#cover")'); + expect(out).toContain('src="/api/html-assets/t/app.js"'); + expect(out).toContain('src="/api/html-assets/t/images/logo.png"'); + expect(out).toContain('srcset="/api/html-assets/t/small.png 1x, /api/html-assets/t/large.png 2x"'); + expect(out).toContain('background-image: url("/api/html-assets/t/inline-bg.webp")'); + expect(out).toContain('src="/api/html-assets/t/movie.mp4"'); + expect(out).toContain('poster="/api/html-assets/t/poster.jpg"'); + expect(out).toContain('src="/api/html-assets/t/intro.mp3"'); + }); + + test("leaves external, root-relative, data, anchors, and navigation links alone", () => { + const html = ` + + +Other page +Section + + +`; + + const out = rewrite(html); + + expect(out).toContain('href="https://cdn.example.com/app.css"'); + expect(out).toContain('href="/site.css"'); + expect(out).toContain('href="other.html"'); + expect(out).toContain('href="#section"'); + expect(out).toContain('src="data:image/png;base64,abc"'); + expect(out).toContain('src="//cdn.example.com/image.png"'); + }); + + test("does not rewrite traversal or unknown extension asset refs", () => { + const out = rewrite(''); + + expect(out).toContain('src="../secret.png"'); + expect(out).toContain('src="server"'); + }); +}); + +describe("rewriteCssAssetReferences", () => { + test("rewrites local url() references relative to the stylesheet path", () => { + const css = ` +body { background: url("../images/bg.png?v=1"); } +@font-face { src: url("./font.woff2") format("woff2"); } +@import "./theme.css"; +.remote { background: url("https://example.test/a.png"); } +`; + + const out = rewriteCssAssetReferences( + css, + (assetPath) => `/assets/${encodeHtmlAssetPath(assetPath)}`, + "styles", + ); + + expect(out).toContain('url("/assets/images/bg.png?v=1")'); + expect(out).toContain('url("/assets/styles/font.woff2")'); + expect(out).toContain('@import url("/assets/styles/theme.css")'); + expect(out).toContain('url("https://example.test/a.png")'); + }); +}); + +describe("html asset route helpers", () => { + test("normalizes valid route paths", () => { + expect(normalizeHtmlAssetRoutePath("assets/logo%20small.png")).toBe("assets/logo small.png"); + expect(normalizeHtmlAssetRoutePath("./assets/../logo.svg")).toBe("logo.svg"); + expect(normalizeHtmlAssetRoutePath("assets/100%2525%20done.png")).toBe("assets/100%25 done.png"); + }); + + test("rejects traversal and invalid encodings", () => { + expect(normalizeHtmlAssetRoutePath("../logo.png")).toBeNull(); + expect(normalizeHtmlAssetRoutePath("..%2Flogo.png")).toBeNull(); + expect(normalizeHtmlAssetRoutePath("%E0%A4%A")).toBeNull(); + }); + + test("returns expected content types", () => { + expect(htmlAssetContentType("style.css")).toBe("text/css; charset=utf-8"); + expect(htmlAssetContentType("font.woff2")).toBe("font/woff2"); + expect(htmlAssetContentType("site.webmanifest")).toBe("application/manifest+json; charset=utf-8"); + expect(htmlAssetContentType("image.unknown")).toBeNull(); + }); +}); diff --git a/packages/shared/html-assets.ts b/packages/shared/html-assets.ts new file mode 100644 index 00000000..bd08a40c --- /dev/null +++ b/packages/shared/html-assets.ts @@ -0,0 +1,308 @@ +import { posix as pathPosix } from "path"; +import * as parse5 from "parse5"; + +export const HTML_ASSET_ROUTE_PREFIX = "/api/html-assets"; + +const CONTENT_TYPES_BY_EXT: Record = { + ".apng": "image/apng", + ".avif": "image/avif", + ".css": "text/css; charset=utf-8", + ".gif": "image/gif", + ".ico": "image/x-icon", + ".jpeg": "image/jpeg", + ".jpg": "image/jpeg", + ".js": "application/javascript; charset=utf-8", + ".json": "application/json; charset=utf-8", + ".mjs": "application/javascript; charset=utf-8", + ".mp3": "audio/mpeg", + ".mp4": "video/mp4", + ".ogg": "audio/ogg", + ".otf": "font/otf", + ".png": "image/png", + ".svg": "image/svg+xml", + ".ttf": "font/ttf", + ".wav": "audio/wav", + ".webmanifest": "application/manifest+json; charset=utf-8", + ".webm": "video/webm", + ".webp": "image/webp", + ".woff": "font/woff", + ".woff2": "font/woff2", + ".xml": "application/xml; charset=utf-8", +}; + +interface HtmlAttr { + name: string; + value: string; +} + +interface HtmlNode { + tagName?: string; + attrs?: HtmlAttr[]; + childNodes?: HtmlNode[]; + value?: string; +} + +type HtmlAssetUrlMapper = (assetPath: string) => string | null; + +export function htmlAssetContentType(assetPath: string): string | null { + return CONTENT_TYPES_BY_EXT[pathPosix.extname(assetPath).toLowerCase()] ?? null; +} + +export function encodeHtmlAssetPath(assetPath: string): string { + return assetPath.split("/").map(encodeURIComponent).join("/"); +} + +export function normalizeHtmlAssetRoutePath(routePath: string): string | null { + const decoded = decodeUrlPath(routePath); + if (decoded === null) return null; + return normalizeDecodedLocalAssetPath(decoded); +} + +export function rewriteHtmlAssetReferences( + html: string, + assetUrlFor: HtmlAssetUrlMapper, +): string { + const tree = looksLikeFullDocument(html) + ? parse5.parse(html) + : parse5.parseFragment(html); + visit(tree as unknown as HtmlNode, (node) => rewriteNodeAssetReferences(node, assetUrlFor)); + return parse5.serialize(tree as never); +} + +export function rewriteCssAssetReferences( + css: string, + assetUrlFor: HtmlAssetUrlMapper, + basePath = "", +): string { + let rewritten = css.replace( + /url\(\s*(["']?)([^"')]+)\1\s*\)/gi, + (match, _quote: string, value: string) => { + const next = rewriteLocalAssetUrl(value, assetUrlFor, basePath); + return next === null ? match : `url("${next}")`; + }, + ); + + rewritten = rewritten.replace( + /@import\s+(?:url\(\s*)?(["'])([^"']+)\1\s*\)?/gi, + (match, _quote: string, value: string) => { + const next = rewriteLocalAssetUrl(value, assetUrlFor, basePath); + return next === null ? match : `@import url("${next}")`; + }, + ); + + return rewritten; +} + +function rewriteNodeAssetReferences( + node: HtmlNode, + assetUrlFor: HtmlAssetUrlMapper, +): void { + const tagName = node.tagName?.toLowerCase(); + if (!tagName) return; + + rewriteStyleAttr(node, assetUrlFor); + + if (tagName === "style") { + rewriteStyleContent(node, assetUrlFor); + return; + } + + if (tagName === "img") { + rewriteAttr(node, "src", assetUrlFor); + rewriteSrcsetAttr(node, "srcset", assetUrlFor); + return; + } + + if (tagName === "source") { + rewriteAttr(node, "src", assetUrlFor); + rewriteSrcsetAttr(node, "srcset", assetUrlFor); + return; + } + + if (tagName === "video") { + rewriteAttr(node, "src", assetUrlFor); + rewriteAttr(node, "poster", assetUrlFor); + return; + } + + if (tagName === "audio" || tagName === "script") { + rewriteAttr(node, "src", assetUrlFor); + return; + } + + if (tagName === "link" && isSupportLink(node)) { + rewriteAttr(node, "href", assetUrlFor); + } +} + +function visit(node: HtmlNode, fn: (node: HtmlNode) => void): void { + fn(node); + for (const child of node.childNodes ?? []) visit(child, fn); +} + +function rewriteAttr( + node: HtmlNode, + name: string, + assetUrlFor: HtmlAssetUrlMapper, +): void { + const attr = findAttr(node, name); + if (!attr) return; + const rewritten = rewriteLocalAssetUrl(attr.value, assetUrlFor); + if (rewritten !== null) attr.value = rewritten; +} + +function rewriteSrcsetAttr( + node: HtmlNode, + name: string, + assetUrlFor: HtmlAssetUrlMapper, +): void { + const attr = findAttr(node, name); + if (!attr) return; + const rewritten = rewriteSrcset(attr.value, assetUrlFor); + if (rewritten !== attr.value) attr.value = rewritten; +} + +function findAttr(node: HtmlNode, name: string): HtmlAttr | null { + const target = name.toLowerCase(); + return node.attrs?.find((attr) => attr.name.toLowerCase() === target) ?? null; +} + +function attrValue(node: HtmlNode, name: string): string | null { + return findAttr(node, name)?.value.trim() ?? null; +} + +function rewriteStyleAttr(node: HtmlNode, assetUrlFor: HtmlAssetUrlMapper): void { + const attr = findAttr(node, "style"); + if (!attr) return; + attr.value = rewriteCssAssetReferences(attr.value, assetUrlFor); +} + +function rewriteStyleContent(node: HtmlNode, assetUrlFor: HtmlAssetUrlMapper): void { + for (const child of node.childNodes ?? []) { + if (typeof child.value === "string") { + child.value = rewriteCssAssetReferences(child.value, assetUrlFor); + } + } +} + +function isSupportLink(node: HtmlNode): boolean { + const rel = attrValue(node, "rel"); + if (!rel) return false; + const tokens = new Set(rel.toLowerCase().split(/\s+/).filter(Boolean)); + return ( + tokens.has("stylesheet") || + tokens.has("preload") || + tokens.has("modulepreload") || + tokens.has("icon") || + tokens.has("apple-touch-icon") || + tokens.has("mask-icon") || + tokens.has("manifest") + ); +} + +function rewriteLocalAssetUrl( + value: string, + assetUrlFor: HtmlAssetUrlMapper, + basePath = "", +): string | null { + const trimmed = value.trim(); + if (shouldSkipUrl(trimmed)) return null; + + const { path, suffix } = splitPathSuffix(trimmed); + const normalized = normalizeLocalAssetPath( + basePath ? pathPosix.join(basePath, path) : path, + ); + if (normalized === null) return null; + if (htmlAssetContentType(normalized) === null) return null; + + const next = assetUrlFor(normalized); + if (next === null) return null; + return /^data:/i.test(next) ? next : `${next}${suffix}`; +} + +function rewriteSrcset( + srcset: string, + assetUrlFor: HtmlAssetUrlMapper, +): string { + const rewritten: string[] = []; + let changed = false; + let i = 0; + + while (i < srcset.length) { + while (i < srcset.length && /[\s,]/u.test(srcset[i])) i++; + if (i >= srcset.length) break; + + const urlStart = i; + while (i < srcset.length && !/[\s,]/u.test(srcset[i])) i++; + + if (srcset.slice(urlStart, i).toLowerCase().startsWith("data:")) { + while (i < srcset.length && !/\s/u.test(srcset[i])) i++; + } + + const originalUrl = srcset.slice(urlStart, i); + const descriptorStart = i; + while (i < srcset.length && srcset[i] !== ",") i++; + const descriptor = srcset.slice(descriptorStart, i).trim(); + + const nextUrl = rewriteLocalAssetUrl(originalUrl, assetUrlFor) ?? originalUrl; + if (nextUrl !== originalUrl) changed = true; + rewritten.push(descriptor ? `${nextUrl} ${descriptor}` : nextUrl); + + if (srcset[i] === ",") i++; + } + + return changed ? rewritten.join(", ") : srcset; +} + +function shouldSkipUrl(value: string): boolean { + if (!value || value.startsWith("#") || value.startsWith("/") || value.startsWith("//")) { + return true; + } + return /^[a-z][a-z0-9+.-]*:/i.test(value); +} + +function splitPathSuffix(value: string): { path: string; suffix: string } { + const queryIndex = value.indexOf("?"); + const hashIndex = value.indexOf("#"); + let splitAt = -1; + if (queryIndex >= 0) splitAt = queryIndex; + if (hashIndex >= 0 && (splitAt < 0 || hashIndex < splitAt)) splitAt = hashIndex; + if (splitAt < 0) return { path: value, suffix: "" }; + return { path: value.slice(0, splitAt), suffix: value.slice(splitAt) }; +} + +function decodeUrlPath(value: string): string | null { + try { + return value + .split("/") + .map((segment) => decodeURIComponent(segment)) + .join("/"); + } catch { + return null; + } +} + +function normalizeLocalAssetPath(value: string): string | null { + const decoded = decodeUrlPath(value.trim()); + if (decoded === null) return null; + return normalizeDecodedLocalAssetPath(decoded); +} + +function normalizeDecodedLocalAssetPath(value: string): string | null { + const normalized = pathPosix.normalize(value.trim().replace(/\\/g, "/")); + if ( + normalized === "." || + normalized === ".." || + normalized.startsWith("../") || + normalized.startsWith("/") || + normalized.includes("\0") || + /[\u0000-\u001f]/u.test(normalized) + ) { + return null; + } + return normalized; +} + +function looksLikeFullDocument(html: string): boolean { + return /]|]|]/i.test(html); +} diff --git a/packages/shared/html-to-markdown.ts b/packages/shared/html-to-markdown.ts index c13189d8..028bfa01 100644 --- a/packages/shared/html-to-markdown.ts +++ b/packages/shared/html-to-markdown.ts @@ -1,8 +1,8 @@ /** * HTML-to-Markdown conversion via Turndown. * - * Shared between the CLI (single HTML file / URL) and the server - * (on-demand conversion for HTML files in folder mode). + * Shared between URL fetching, `annotate --markdown`, and the server's + * on-demand conversion for HTML files in folder mode. */ import TurndownService from "turndown"; diff --git a/packages/shared/package.json b/packages/shared/package.json index efd8aeb4..31b320cf 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -40,6 +40,8 @@ "./worktree-pool": "./worktree-pool.ts", "./html-to-markdown": "./html-to-markdown.ts", "./url-to-markdown": "./url-to-markdown.ts", + "./html-assets": "./html-assets.ts", + "./html-assets-node": "./html-assets-node.ts", "./tour": "./tour.ts", "./annotate-args": "./annotate-args.ts", "./at-reference": "./at-reference.ts", @@ -50,6 +52,7 @@ }, "dependencies": { "@joplin/turndown-plugin-gfm": "^1.0.64", + "parse5": "^7.3.0", "turndown": "^7.2.4" } } diff --git a/packages/shared/url-to-markdown.ts b/packages/shared/url-to-markdown.ts index b6c96f55..19f68e92 100644 --- a/packages/shared/url-to-markdown.ts +++ b/packages/shared/url-to-markdown.ts @@ -23,7 +23,7 @@ export const isConvertedSource = (source: UrlToMarkdownResult["source"]): boolea source === "jina" || source === "fetch+turndown"; const FETCH_TIMEOUT_MS = 30_000; -const MAX_BODY_BYTES = 10 * 1024 * 1024; // 10 MB — matches local HTML file guard +const MAX_BODY_BYTES = 10 * 1024 * 1024; /** * Skip Jina for local/private URLs — fetch them directly instead. diff --git a/packages/ui/components/ExportModal.tsx b/packages/ui/components/ExportModal.tsx index 251001f0..4d86e6fe 100644 --- a/packages/ui/components/ExportModal.tsx +++ b/packages/ui/components/ExportModal.tsx @@ -25,7 +25,7 @@ interface ExportModalProps { /** Error from the last short URL generation attempt (empty string = no error) */ shortUrlError?: string; /** Generate a short URL on demand (user clicks "Create short link") */ - onGenerateShortUrl?: () => void; + onGenerateShortUrl?: () => void | Promise; annotationsOutput: string; annotationCount: number; taterSprite?: React.ReactNode; diff --git a/packages/ui/hooks/useLinkedDoc.ts b/packages/ui/hooks/useLinkedDoc.ts index b6c62940..acee5d48 100644 --- a/packages/ui/hooks/useLinkedDoc.ts +++ b/packages/ui/hooks/useLinkedDoc.ts @@ -24,8 +24,10 @@ export interface UseLinkedDocOptions { * swaps these to render raw; back() restores the base values from this snapshot. */ renderAs: 'markdown' | 'html'; rawHtml: string; + shareHtml: string; setRenderAs: (r: 'markdown' | 'html') => void; setRawHtml: (html: string) => void; + setShareHtml: (html: string) => void; viewerRef: React.RefObject; sidebar: { open: (tab?: SidebarTab) => void }; /** Absolute path of the primary document — enables getDocAnnotations() to include @@ -43,6 +45,7 @@ interface SavedPlanState { globalAttachments: ImageAttachment[]; renderAs: 'markdown' | 'html'; rawHtml: string; + shareHtml: string; } export interface CachedDocState { @@ -96,8 +99,10 @@ export function useLinkedDoc(options: UseLinkedDocOptions): UseLinkedDocReturn { setGlobalAttachments, renderAs, rawHtml, + shareHtml, setRenderAs, setRawHtml, + setShareHtml, viewerRef, sidebar, sourceFilePath, @@ -134,6 +139,7 @@ export function useLinkedDoc(options: UseLinkedDocOptions): UseLinkedDocReturn { isConverted?: boolean; renderAs?: 'markdown' | 'html'; rawHtml?: string; + shareHtml?: string; error?: string; matches?: string[]; }; @@ -167,6 +173,7 @@ export function useLinkedDoc(options: UseLinkedDocOptions): UseLinkedDocReturn { globalAttachments: [...globalAttachments], renderAs, rawHtml, + shareHtml, }; let total = annotations.length + globalAttachments.length; for (const [fp, cached] of docCache.current.entries()) { @@ -202,6 +209,7 @@ export function useLinkedDoc(options: UseLinkedDocOptions): UseLinkedDocReturn { const docRenderAs = data.renderAs === 'html' ? 'html' : 'markdown'; setRenderAs(docRenderAs); setRawHtml(docRenderAs === 'html' ? (data.rawHtml ?? '') : ''); + setShareHtml(docRenderAs === 'html' ? (data.shareHtml ?? '') : ''); setMarkdown(docRenderAs === 'html' ? '' : (data.markdown ?? '')); setAnnotations(cached?.annotations ?? []); setGlobalAttachments(cached?.globalAttachments ?? []); @@ -233,6 +241,7 @@ export function useLinkedDoc(options: UseLinkedDocOptions): UseLinkedDocReturn { globalAttachments, renderAs, rawHtml, + shareHtml, linkedDoc, setMarkdown, setAnnotations, @@ -240,6 +249,7 @@ export function useLinkedDoc(options: UseLinkedDocOptions): UseLinkedDocReturn { setGlobalAttachments, setRenderAs, setRawHtml, + setShareHtml, viewerRef, sidebar, ] @@ -271,6 +281,7 @@ export function useLinkedDoc(options: UseLinkedDocOptions): UseLinkedDocReturn { const saved = savedPlanState.current; setRenderAs(saved.renderAs); setRawHtml(saved.rawHtml); + setShareHtml(saved.shareHtml); setMarkdown(saved.markdown); setAnnotations(saved.annotations); setGlobalAttachments(saved.globalAttachments); @@ -296,6 +307,7 @@ export function useLinkedDoc(options: UseLinkedDocOptions): UseLinkedDocReturn { setGlobalAttachments, setRenderAs, setRawHtml, + setShareHtml, viewerRef, ]); @@ -317,6 +329,7 @@ export function useLinkedDoc(options: UseLinkedDocOptions): UseLinkedDocReturn { markdown: savedPlanState.current.markdown, renderAs: savedPlanState.current.renderAs, rawHtml: savedPlanState.current.rawHtml, + shareHtml: savedPlanState.current.shareHtml, annotations: [...savedPlanState.current.annotations], selectedAnnotationId: savedPlanState.current.selectedAnnotationId, globalAttachments: [...savedPlanState.current.globalAttachments], @@ -325,13 +338,14 @@ export function useLinkedDoc(options: UseLinkedDocOptions): UseLinkedDocReturn { markdown, renderAs, rawHtml, + shareHtml, annotations: [...annotations], selectedAnnotationId, globalAttachments: [...globalAttachments], }; return { root, docs }; - }, [linkedDoc, annotations, globalAttachments, markdown, renderAs, rawHtml, selectedAnnotationId]); + }, [linkedDoc, annotations, globalAttachments, markdown, renderAs, rawHtml, shareHtml, selectedAnnotationId]); const restoreSession = useCallback((state: LinkedDocSessionState) => { viewerRef.current?.clearAllHighlights(); @@ -345,6 +359,9 @@ export function useLinkedDoc(options: UseLinkedDocOptions): UseLinkedDocReturn { setDocAnnotationCount(total); setMarkdown(state.root.markdown); + setRenderAs(state.root.renderAs); + setRawHtml(state.root.rawHtml); + setShareHtml(state.root.shareHtml); setAnnotations([...state.root.annotations]); setGlobalAttachments([...state.root.globalAttachments]); setSelectedAnnotationId(state.root.selectedAnnotationId); @@ -362,6 +379,9 @@ export function useLinkedDoc(options: UseLinkedDocOptions): UseLinkedDocReturn { setAnnotations, setSelectedAnnotationId, setGlobalAttachments, + setRenderAs, + setRawHtml, + setShareHtml, viewerRef, ]); diff --git a/packages/ui/hooks/useSharing.ts b/packages/ui/hooks/useSharing.ts index 7d5bb984..67e7e5c7 100644 --- a/packages/ui/hooks/useSharing.ts +++ b/packages/ui/hooks/useSharing.ts @@ -64,7 +64,7 @@ interface UseSharingResult { refreshShareUrl: () => Promise; /** Generate a short URL via the paste service (user must explicitly trigger this) */ - generateShortUrl: () => Promise; + generateShortUrl: () => Promise; /** Import annotations from a teammate's share URL */ importFromShareUrl: (url: string) => Promise; @@ -100,7 +100,9 @@ export function useSharing( shareBaseUrl?: string, pasteApiUrl?: string, rawHtml?: string, + resolveRawHtmlForShare?: () => Promise, setRawHtml?: (h: string) => void, + setShareHtml?: (h: string) => void, setRenderAs?: (m: 'markdown' | 'html') => void, ): UseSharingResult { const [isSharedSession, setIsSharedSession] = useState(false); @@ -141,22 +143,22 @@ export function useSharing( if (payload) { if (payload.h && payload.r === 'html') { setRawHtml?.(payload.h); + setShareHtml?.(payload.h); setRenderAs?.('html'); setMarkdown(''); } else { setMarkdown(payload.p); setRenderAs?.('markdown'); setRawHtml?.(''); + setShareHtml?.(''); } const restoredAnnotations = fromShareable(payload.a, payload.d, payload.s); setAnnotations(restoredAnnotations); - if (payload.g?.length) { - const parsed = parseShareableImages(payload.g) ?? []; - setGlobalAttachments(parsed); - setSharedGlobalAttachments(parsed); - } + const parsedGlobalAttachments = parseShareableImages(payload.g) ?? []; + setGlobalAttachments(parsedGlobalAttachments); + setSharedGlobalAttachments(parsedGlobalAttachments.length ? parsedGlobalAttachments : null); setPendingSharedAnnotations(restoredAnnotations); setIsSharedSession(true); @@ -188,24 +190,23 @@ export function useSharing( if (payload) { if (payload.h && payload.r === 'html') { setRawHtml?.(payload.h); + setShareHtml?.(payload.h); setRenderAs?.('html'); setMarkdown(''); } else { setMarkdown(payload.p); setRenderAs?.('markdown'); setRawHtml?.(''); + setShareHtml?.(''); } // Convert shareable annotations to full annotations const restoredAnnotations = fromShareable(payload.a, payload.d, payload.s); setAnnotations(restoredAnnotations); - // Restore global attachments if present - if (payload.g?.length) { - const parsed = parseShareableImages(payload.g) ?? []; - setGlobalAttachments(parsed); - setSharedGlobalAttachments(parsed); - } + const parsedGlobalAttachments = parseShareableImages(payload.g) ?? []; + setGlobalAttachments(parsedGlobalAttachments); + setSharedGlobalAttachments(parsedGlobalAttachments.length ? parsedGlobalAttachments : null); // Store for later application to DOM setPendingSharedAnnotations(restoredAnnotations); @@ -236,7 +237,7 @@ export function useSharing( setShareLoadError('Failed to load shared plan — an unexpected error occurred.'); return false; } - }, [setMarkdown, setAnnotations, setGlobalAttachments, onSharedLoad, pasteApiUrl, setRawHtml, setRenderAs]); + }, [setMarkdown, setAnnotations, setGlobalAttachments, onSharedLoad, pasteApiUrl, setRawHtml, setShareHtml, setRenderAs]); // Load from hash on mount useEffect(() => { @@ -281,7 +282,7 @@ export function useSharing( if (isSharedRef.current) { isSharedRef.current = false; return; } setShortShareUrl(''); setShortUrlError(''); - }, [markdown, annotations, rawHtml, isSharedSession]); + }, [markdown, annotations, globalAttachments, rawHtml, isSharedSession]); /** * Generate a short URL via the paste service. @@ -289,34 +290,40 @@ export function useSharing( * Clears the short URL if the service is unavailable — the full * hash-based URL remains usable as a fallback. */ - const generateShortUrl = useCallback(async () => { - if (!markdown && !rawHtml) return; + const generateShortUrl = useCallback(async (): Promise => { + if (!markdown && !rawHtml) return null; setIsGeneratingShortUrl(true); setShortUrlError(''); try { + const htmlForShare = rawHtml + ? (await resolveRawHtmlForShare?.()) ?? rawHtml + : undefined; const result = await createShortShareUrl( markdown, annotations, globalAttachments, { pasteApiUrl, shareBaseUrl }, - rawHtml, + htmlForShare, ); if (result) { setShortShareUrl(result.shortUrl); + return result.shortUrl; } else { setShortShareUrl(''); setShortUrlError('Short URL service unavailable'); + return null; } - } catch { + } catch (e) { setShortShareUrl(''); - setShortUrlError('Failed to generate short URL'); + setShortUrlError(e instanceof Error ? e.message : 'Failed to generate short URL'); + return null; } finally { setIsGeneratingShortUrl(false); } - }, [markdown, annotations, globalAttachments, shareBaseUrl, pasteApiUrl, rawHtml]); + }, [markdown, annotations, globalAttachments, shareBaseUrl, pasteApiUrl, rawHtml, resolveRawHtmlForShare]); // Import annotations from a teammate's share URL (supports both hash-based and short /p/ URLs) const importFromShareUrl = useCallback(async (url: string): Promise => { diff --git a/packages/ui/utils/sharing.ts b/packages/ui/utils/sharing.ts index 3be5ab26..8b473452 100644 --- a/packages/ui/utils/sharing.ts +++ b/packages/ui/utils/sharing.ts @@ -27,7 +27,7 @@ export interface SharePayload { g?: ShareableImage[]; // global attachments (path strings or [path, name] tuples) d?: (string | null)[]; // diffContext per annotation, parallel to `a` s?: (string | undefined)[]; // source per annotation (external tool identifier), parallel to `a` - h?: string; // raw HTML content (render-html mode) + h?: string; // raw HTML content (direct HTML rendering mode) r?: 'html'; // render mode flag (omitted = markdown) } @@ -220,6 +220,13 @@ export function formatUrlSize(url: string): string { const DEFAULT_PASTE_API = 'https://plannotator-paste.plannotator.workers.dev'; const DEFAULT_SHARE_BASE = 'https://share.plannotator.ai'; +export class ShortShareUrlError extends Error { + constructor(message: string) { + super(message); + this.name = 'ShortShareUrlError'; + } +} + /** * Create a short share URL by posting compressed plan data to the paste service. * @@ -269,6 +276,9 @@ export async function createShortShareUrl( }); if (!response.ok) { + if (response.status === 413) { + throw new ShortShareUrlError(await readPasteError(response, 'Share payload is too large')); + } console.warn(`[sharing] Paste service returned ${response.status}`); return null; } @@ -283,6 +293,9 @@ export async function createShortShareUrl( return { shortUrl, id: result.id }; } catch (e) { + if (e instanceof ShortShareUrlError) { + throw e; + } // Service unavailable — expected for self-hosted setups without a paste backend. // The caller is responsible for falling back to hash-based sharing silently. console.debug('[sharing] Short URL service unavailable, using hash-based sharing:', e); @@ -290,6 +303,15 @@ export async function createShortShareUrl( } } +async function readPasteError(response: Response, fallback: string): Promise { + try { + const body = (await response.json()) as { error?: unknown }; + return typeof body.error === 'string' && body.error.trim() ? body.error : fallback; + } catch { + return fallback; + } +} + /** * Load plan data from a paste service using the paste ID embedded in a short URL. *