Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion packages/pm-desktop/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@devintern/pm-desktop",
"productName": "DevIntern PM",
"version": "0.9.11",
"version": "0.9.12",
"private": true,
"description": "Desktop app for @devintern/pm — multi-ticket AI task creation for your tracker.",
"author": "DevIntern <hello@getdevintern.com>",
Expand Down Expand Up @@ -39,6 +39,7 @@
"@tanstack/react-query": "^5.101.4",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"dompurify": "3.3.0",
"electron-updater": "^6.6.2",
"lucide-react": "^1.14.0",
"posthog-node": "^5.48.0",
Expand Down
237 changes: 237 additions & 0 deletions packages/pm-desktop/src/renderer/src/components/ReleaseNotes.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,237 @@
import React from "react";
import DOMPurify from "dompurify";

const DROPPED_TAGS = [
"applet",
"audio",
"base",
"button",
"canvas",
"embed",
"form",
"frame",
"frameset",
"iframe",
"img",
"input",
"link",
"math",
"meta",
"noscript",
"object",
"picture",
"plaintext",
"script",
"select",
"source",
"style",
"svg",
"template",
"textarea",
"track",
"video",
"xmp",
];

const TAG_CLASSES: Record<string, string | undefined> = {
article: "space-y-1.5",
blockquote: "my-2 border-l-2 border-border pl-3 italic text-muted-foreground",
code: "rounded bg-muted px-1 py-0.5 font-mono text-[0.9em]",
del: "text-muted-foreground line-through",
div: "space-y-1.5",
em: "italic",
h1: "mt-2 text-base font-semibold text-foreground",
h2: "mt-2 text-sm font-semibold text-foreground",
h3: "mt-2 text-sm font-medium text-foreground",
h4: "mt-1.5 text-xs font-medium text-foreground",
h5: "mt-1.5 text-xs font-medium text-foreground",
h6: "mt-1.5 text-xs font-medium text-foreground",
li: "pl-0.5",
ol: "my-1.5 list-decimal space-y-0.5 pl-5",
p: "my-1.5",
pre: "my-2 overflow-x-auto rounded-md bg-muted p-2 font-mono text-xs",
section: "space-y-1.5",
strong: "font-semibold text-foreground",
table: "my-2 w-full border-collapse text-left text-xs",
td: "border border-border px-2 py-1 align-top",
th: "border border-border bg-muted px-2 py-1 font-medium",
ul: "my-1.5 list-disc space-y-0.5 pl-5",
};

const ALLOWED_TAGS = [
...Object.keys(TAG_CLASSES),
"a",
"b",
"br",
"hr",
"i",
"s",
"span",
"tbody",
"thead",
"tr",
"u",
];

const ALLOWED_TAG_SET = new Set(ALLOWED_TAGS);

const MAX_RELEASE_NOTES_HTML_LENGTH = 20_000;
const MAX_RELEASE_NOTES_DEPTH = 24;
const MAX_RELEASE_NOTES_NODES = 1_000;

/** Release-note links are external, so only absolute HTTP(S) URLs are usable. */
export function isSafeReleaseNotesUrl(value: string): boolean {
const trimmed = value.trim();
if (!/^https?:\/\//i.test(trimmed)) return false;
try {
const url = new URL(trimmed);
return url.protocol === "http:" || url.protocol === "https:";
} catch {
return false;
}
}

interface RenderResult {
meaningful: boolean;
nodes: React.ReactNode[];
}

interface RenderBudget {
exceeded: boolean;
remainingNodes: number;
}

function renderNodes(
nodes: NodeListOf<ChildNode>,
keyPrefix: string,
depth: number,
budget: RenderBudget,
): RenderResult {
const output: React.ReactNode[] = [];
let meaningful = false;

if (depth > MAX_RELEASE_NOTES_DEPTH) {
budget.exceeded = true;
return { meaningful, nodes: output };
}

for (let index = 0; index < nodes.length; index++) {
if (budget.remainingNodes === 0) {
budget.exceeded = true;
break;
}
budget.remainingNodes--;

const node = nodes[index];
if (!node) continue;
const key = `${keyPrefix}-${index}`;
if (node.nodeType === 3) {
const text = node.textContent ?? "";
output.push(text);
meaningful ||= text.trim().length > 0;
continue;
}
if (node.nodeType !== 1) continue;

const element = node as Element;
const tag = element.localName.toLowerCase();
// DOMPurify owns sanitization; ignore anything outside its configured
// output contract rather than attempting to render it.
if (!ALLOWED_TAG_SET.has(tag)) continue;

const children = renderNodes(element.childNodes, key, depth + 1, budget);
if (budget.exceeded) break;

if (tag === "br") {
output.push(React.createElement("br", { key }));
continue;
}
if (tag === "hr") {
output.push(React.createElement("hr", { className: "my-2 border-border", key }));
meaningful = true;
continue;
}
if (tag === "a") {
const href = element.getAttribute("href")?.trim() ?? "";
if (!isSafeReleaseNotesUrl(href)) {
output.push(...children.nodes);
meaningful ||= children.meaningful;
continue;
}
const onClick = (event: React.MouseEvent<HTMLButtonElement>) => {
event.preventDefault();
void window.pm.openExternal(href);
};
output.push(
React.createElement(
"button",
{
className:
"cursor-pointer border-0 bg-transparent p-0 text-primary underline underline-offset-2 hover:text-primary/80",
key,
onClick,
title: element.getAttribute("title") ?? undefined,
type: "button",
},
children.nodes,
),
);
meaningful ||= children.meaningful;
continue;
}

const normalizedTag = tag === "b" ? "strong" : tag === "i" ? "em" : tag;
output.push(
React.createElement(
normalizedTag,
{ className: TAG_CLASSES[normalizedTag], key },
children.nodes,
),
);
meaningful ||= children.meaningful;
}

return { meaningful, nodes: output };
}

function renderReleaseNotes(html: string | null | undefined): RenderResult {
if (!html?.trim()) return { meaningful: false, nodes: [] };
if (html.length > MAX_RELEASE_NOTES_HTML_LENGTH) return { meaningful: false, nodes: [] };

try {
const sanitized = DOMPurify(window).sanitize(html, {
ALLOWED_ATTR: ["href", "title"],
ALLOWED_TAGS,
ALLOW_ARIA_ATTR: false,
ALLOW_DATA_ATTR: false,
FORBID_CONTENTS: DROPPED_TAGS,
FORBID_TAGS: DROPPED_TAGS,
RETURN_DOM_FRAGMENT: true,
});
const budget: RenderBudget = {
exceeded: false,
remainingNodes: MAX_RELEASE_NOTES_NODES,
};
const rendered = renderNodes(sanitized.childNodes, "release-note", 0, budget);
return budget.exceeded ? { meaningful: false, nodes: [] } : rendered;
} catch {
return { meaningful: false, nodes: [] };
}
}

export function ReleaseNotes({ html }: { html: string | null | undefined }) {
const rendered = renderReleaseNotes(html);

return (
<div
className="max-h-48 min-w-0 max-w-full overflow-y-auto overscroll-contain break-words pr-1 text-xs leading-relaxed [overflow-wrap:anywhere]"
data-testid="update-notifier-notes"
>
{rendered.meaningful ? (
rendered.nodes
) : (
<p className="text-muted-foreground">Release notes are unavailable.</p>
)}
</div>
);
}
Loading
Loading