[Init] Initial commit - NetMesh terminal manager
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
This commit is contained in:
593
domain/notes/clipboardPaste.test.ts
Normal file
593
domain/notes/clipboardPaste.test.ts
Normal file
@@ -0,0 +1,593 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
convertClipboardHtmlToMarkdown,
|
||||
convertHtmlImgTagToMarkdownOrHtml,
|
||||
convertHtmlIslandsInMarkdown,
|
||||
decodeHtmlEntities,
|
||||
extractBalancedHtmlElement,
|
||||
maskCodeRegions,
|
||||
normalizeLinkedBadgeImages,
|
||||
normalizeNotePublicAssetPaths,
|
||||
normalizePastedNoteMarkdown,
|
||||
plainMarkdownContainsHtml,
|
||||
resolveNoteClipboardPaste,
|
||||
serializeSafeHtmlImage,
|
||||
shouldInterceptResolvedNotePaste,
|
||||
shouldInsertClipboardTextAsMarkdown,
|
||||
unmaskCodeRegions,
|
||||
} from "./clipboardPaste.ts";
|
||||
|
||||
const CATTY_PASTE = `---
|
||||
|
||||
<img width="3142" height="1764" alt="Screenshot 2026-07-02 at 22 51 24" src="https://github.com/user-attachments/assets/3116165d-623a-4d3a-a28a-914befb9b72d" />
|
||||
|
||||
---
|
||||
|
||||
<a name="catty-agent"></a>
|
||||
# 🔥 Catty Agent — Your IT Ops AI Partner
|
||||
|
||||
> 🚀 **Boost your IT ops daily work with AI power.** Catty Agent is the built-in AI assistant that understands your servers, executes commands, and handles complex multi-host operations — all through natural conversation.
|
||||
### 🔥 What can Catty Agent do?
|
||||
|
||||
- 🚀 **Natural language server management** — just tell it what you need, no more memorizing commands
|
||||
- 🔥 **Real-time server diagnostics** — check status, inspect logs, monitor resources through conversation
|
||||
`;
|
||||
|
||||
test("centered README hero blocks wrap in div align=center", () => {
|
||||
const html = `
|
||||
<p align="center">
|
||||
<img src="https://example.com/icon.png" alt="Netcatty" width="128" height="128">
|
||||
</p>
|
||||
<h1 align="center">Netcatty</h1>
|
||||
<p align="center">
|
||||
<strong>🔥 AI-Powered SSH Client</strong><br/>
|
||||
<a href="https://netcatty.app">netcatty.app</a>
|
||||
</p>
|
||||
`;
|
||||
const md = convertClipboardHtmlToMarkdown(html);
|
||||
assert.match(md, /<div align="center">/);
|
||||
assert.match(md, /<\/div>/);
|
||||
assert.match(md, /width="128"/);
|
||||
assert.match(md, /height="128"/);
|
||||
assert.match(md, /# Netcatty/);
|
||||
assert.match(md, /netcatty\.app/);
|
||||
const centerIdx = md.indexOf('<div align="center">');
|
||||
const logoIdx = md.search(/icon\.png|# Netcatty/);
|
||||
assert.ok(centerIdx >= 0 && logoIdx >= 0 && centerIdx < logoIdx);
|
||||
});
|
||||
|
||||
test("island conversion keeps center on p align=center with image", () => {
|
||||
const plain = `
|
||||
<p align="center">
|
||||
<img src="https://example.com/icon.png" alt="Netcatty" width="128" height="128">
|
||||
</p>
|
||||
|
||||
<h1 align="center">Netcatty</h1>
|
||||
`;
|
||||
const md = convertHtmlIslandsInMarkdown(plain);
|
||||
assert.match(md, /<div align="center">/);
|
||||
assert.match(md, /width="128"/);
|
||||
assert.match(md, /Netcatty/);
|
||||
});
|
||||
|
||||
test("relative public/ image paths map to Vite site root (not dropped)", () => {
|
||||
const md = convertHtmlIslandsInMarkdown(
|
||||
'<p align="center"><img src="public/icon.png" alt="Netcatty" width="128" height="128"></p>',
|
||||
);
|
||||
// Vite serves public/ at / — store /icon.png so the browser does not request /public/...
|
||||
assert.match(md, /src="\/icon\.png"/);
|
||||
assert.match(md, /width="128"/);
|
||||
});
|
||||
|
||||
test("turndown converts pure html clipboard", () => {
|
||||
const html = `
|
||||
<html><body>
|
||||
<!--StartFragment-->
|
||||
<h1>Runbook</h1>
|
||||
<p>Restart <strong>sshd</strong> on <em>prod</em>.</p>
|
||||
<ul><li>check logs</li><li>open <a href="https://example.com">docs</a></li></ul>
|
||||
<img alt="shot" src="https://example.com/a.png" />
|
||||
<!--EndFragment-->
|
||||
</body></html>
|
||||
`;
|
||||
const md = convertClipboardHtmlToMarkdown(html);
|
||||
assert.match(md, /^# Runbook/m);
|
||||
assert.match(md, /\*\*sshd\*\*/);
|
||||
assert.match(md, /\[docs\]\(https:\/\/example\.com\)/);
|
||||
assert.match(md, /!\[shot\]\(https:\/\/example\.com\/a\.png\)/);
|
||||
});
|
||||
|
||||
test("screenshot images keep width and height attributes", () => {
|
||||
const md = convertHtmlIslandsInMarkdown(CATTY_PASTE);
|
||||
assert.match(md, /^# 🔥 Catty Agent/m);
|
||||
assert.match(
|
||||
md,
|
||||
/<img\b[^>]*src="https:\/\/github\.com\/user-attachments\/assets\/3116165d-623a-4d3a-a28a-914befb9b72d"/,
|
||||
);
|
||||
assert.match(md, /width="3142"/);
|
||||
assert.match(md, /height="1764"/);
|
||||
assert.match(md, /alt="Screenshot 2026-07-02 at 22 51 24"/);
|
||||
assert.doesNotMatch(md, /\\#/);
|
||||
assert.doesNotMatch(md, /<a\s+name=/i);
|
||||
});
|
||||
|
||||
test("serializeSafeHtmlImage preserves dimensions when present", () => {
|
||||
assert.equal(
|
||||
serializeSafeHtmlImage({
|
||||
src: "https://example.com/a.png",
|
||||
alt: "shot",
|
||||
}),
|
||||
"",
|
||||
);
|
||||
assert.match(
|
||||
serializeSafeHtmlImage({
|
||||
src: "https://example.com/a.png",
|
||||
alt: "shot",
|
||||
width: 3142,
|
||||
height: 1764,
|
||||
}),
|
||||
/<img\b[^>]*width="3142"[^>]*height="1764"[^>]*\/>/,
|
||||
);
|
||||
assert.match(
|
||||
serializeSafeHtmlImage({
|
||||
src: "https://cdn.ko-fi.com/cdn/kofi3.png?v=2",
|
||||
alt: "Support on Ko-fi",
|
||||
width: 150,
|
||||
}),
|
||||
/width="150"/,
|
||||
);
|
||||
assert.match(
|
||||
serializeSafeHtmlImage({
|
||||
src: "https://example.com/icon.png",
|
||||
alt: "icon",
|
||||
height: 24,
|
||||
}),
|
||||
/<img\b[^>]*height="24"[^>]*\/>/,
|
||||
);
|
||||
});
|
||||
|
||||
test("serializeSafeHtmlImage keeps relative paths; rejects data/javascript", () => {
|
||||
// Relative README paths are kept (may 404 in-app, but must not vanish on paste).
|
||||
assert.equal(
|
||||
serializeSafeHtmlImage({ src: "./docs/screenshot.png", alt: "shot" }),
|
||||
"",
|
||||
);
|
||||
assert.equal(
|
||||
serializeSafeHtmlImage({ src: "public/icon.png", alt: "logo" }),
|
||||
"",
|
||||
);
|
||||
// Protocol-relative → https (covered more fully below; keep here as non-drop).
|
||||
assert.equal(
|
||||
serializeSafeHtmlImage({
|
||||
src: "//cdn.example.com/a.png",
|
||||
alt: "cdn",
|
||||
}),
|
||||
"",
|
||||
);
|
||||
assert.equal(
|
||||
serializeSafeHtmlImage({ src: "data:image/png;base64,aaa", alt: "x" }),
|
||||
"",
|
||||
);
|
||||
assert.equal(
|
||||
serializeSafeHtmlImage({ src: "javascript:alert(1)", alt: "x" }),
|
||||
"",
|
||||
);
|
||||
});
|
||||
|
||||
test("linked badge images stay as images (tight single-line / a>img), not text-only", () => {
|
||||
const source = [
|
||||
"[](https://github.com/binaricat/Netcatty/releases/latest)",
|
||||
"",
|
||||
"[ ",
|
||||
"",
|
||||
" ](#)",
|
||||
"",
|
||||
"[",
|
||||
'<img alt="Support on Ko-fi" width="150" src="https://cdn.ko-fi.com/cdn/kofi3.png?v=2" />',
|
||||
"](https://ko-fi.com/binaricat)",
|
||||
"",
|
||||
'<a href="https://example.com/dl"><img alt="Download" src="https://img.shields.io/badge/Download-latest-success" /></a>',
|
||||
].join("\n");
|
||||
|
||||
const md = normalizeLinkedBadgeImages(source);
|
||||
// Markdown linked image kept (with image src), not reduced to text-only [GitHub Release](url).
|
||||
assert.match(
|
||||
md,
|
||||
/\[!\[GitHub Release\]\(https:\/\/img\.shields\.io\/github\/v\/release\/binaricat\/Netcatty\)\]\(https:\/\/github\.com\/binaricat\/Netcatty\/releases\/latest\)/,
|
||||
);
|
||||
assert.match(md, /\[!\[Platform\]\(https:\/\/img\.shields\.io\/badge\/Platform-macOS-blue\)\]\(#\)/);
|
||||
// HTML img with width inside link → <a><img width></a>
|
||||
assert.match(md, /<a href="https:\/\/ko-fi\.com\/binaricat"><img\b[^>]*src="https:\/\/cdn\.ko-fi\.com\/cdn\/kofi3\.png\?v=2"/);
|
||||
// Dimension-less shield inside <a> → linked markdown image
|
||||
assert.match(
|
||||
md,
|
||||
/\[!\[Download\]\(https:\/\/img\.shields\.io\/badge\/Download-latest-success\)\]\(https:\/\/example\.com\/dl\)/,
|
||||
);
|
||||
assert.doesNotMatch(md, /^\s*\]\(/m);
|
||||
// Not text-only badge (must keep image syntax).
|
||||
assert.doesNotMatch(
|
||||
md,
|
||||
/(?<!!)\[GitHub Release\]\(https:\/\/github\.com\/binaricat\/Netcatty\/releases\/latest\)/,
|
||||
);
|
||||
});
|
||||
|
||||
test("normalize removes orphan link closers but keeps image dimensions", () => {
|
||||
const messy = [
|
||||
"Intro",
|
||||
"](https://example.com/orphan)",
|
||||
'<img width="2000" height="1000" alt="wide" src="https://example.com/w.png" />',
|
||||
"Done",
|
||||
].join("\n");
|
||||
const md = normalizePastedNoteMarkdown(messy);
|
||||
assert.doesNotMatch(md, /\]\(https:\/\/example\.com\/orphan\)/);
|
||||
assert.match(md, /src="https:\/\/example\.com\/w\.png"/);
|
||||
assert.match(md, /width="2000"/);
|
||||
assert.match(md, /height="1000"/);
|
||||
});
|
||||
|
||||
test("normalize keeps link-closer lines inside fenced and indented code", () => {
|
||||
const source = [
|
||||
"Before",
|
||||
"](https://example.com/orphan)",
|
||||
"```md",
|
||||
"](https://example.com)",
|
||||
"```",
|
||||
"",
|
||||
" ](https://example.com/indented)",
|
||||
"After",
|
||||
].join("\n");
|
||||
const md = normalizePastedNoteMarkdown(source);
|
||||
assert.doesNotMatch(md, /^\]\(https:\/\/example\.com\/orphan\)$/m);
|
||||
assert.match(md, /```md\n\]\(https:\/\/example\.com\)\n```/);
|
||||
assert.match(md, /^ {4}\]\(https:\/\/example\.com\/indented\)$/m);
|
||||
});
|
||||
|
||||
test("resolve pastes Catty-style mixed markdown+html with image sizes", () => {
|
||||
const payload = resolveNoteClipboardPaste({
|
||||
plainText: CATTY_PASTE,
|
||||
htmlText: "",
|
||||
});
|
||||
assert.ok(payload.kind === "html-converted" || payload.kind === "markdown");
|
||||
assert.equal(
|
||||
shouldInterceptResolvedNotePaste({
|
||||
editorMode: "edit",
|
||||
pasteInsideCodeBlock: false,
|
||||
payload,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
assert.match(payload.text, /^# 🔥 Catty Agent/m);
|
||||
assert.match(payload.text, /width="3142"/);
|
||||
assert.match(payload.text, /height="1764"/);
|
||||
});
|
||||
|
||||
test("repo README paste collapses shields badges without debris", () => {
|
||||
const readmeHead = readFileSync(new URL("../../README.md", import.meta.url), "utf8").slice(0, 2200);
|
||||
const payload = resolveNoteClipboardPaste({ plainText: readmeHead, htmlText: "" });
|
||||
assert.ok(payload.text.length > 50);
|
||||
assert.doesNotMatch(payload.text, /^\s*\]\([^)\n]+\)\s*$/m);
|
||||
// Large screenshot keeps dimensions in source.
|
||||
assert.match(payload.text, /width="3142"/);
|
||||
assert.match(payload.text, /height="1764"/);
|
||||
});
|
||||
|
||||
test("resolve uses full turndown for browser StartFragment html", () => {
|
||||
const payload = resolveNoteClipboardPaste({
|
||||
plainText: "flat text without structure",
|
||||
htmlText: `
|
||||
<html><body>
|
||||
<!--StartFragment-->
|
||||
<h1>From browser</h1>
|
||||
<p>Hello <b>world</b></p>
|
||||
<img alt="x" src="https://cdn.example.com/x.png" width="2000" height="1000" />
|
||||
<!--EndFragment-->
|
||||
</body></html>
|
||||
`,
|
||||
});
|
||||
assert.equal(payload.kind, "html-converted");
|
||||
assert.match(payload.text, /^# From browser/m);
|
||||
assert.match(payload.text, /\*\*world\*\*/);
|
||||
assert.match(payload.text, /src="https:\/\/cdn\.example\.com\/x\.png"/);
|
||||
assert.match(payload.text, /width="2000"/);
|
||||
assert.match(payload.text, /height="1000"/);
|
||||
});
|
||||
|
||||
test("resolve uses structured plain markdown when html is absent", () => {
|
||||
const payload = resolveNoteClipboardPaste({
|
||||
plainText: "# From .md file\n\n- item",
|
||||
htmlText: "",
|
||||
});
|
||||
assert.equal(payload.kind, "markdown");
|
||||
assert.match(payload.text, /# From \.md file/);
|
||||
});
|
||||
|
||||
test("plain unstructured text is not intercepted", () => {
|
||||
assert.equal(shouldInsertClipboardTextAsMarkdown("hello world"), false);
|
||||
const payload = resolveNoteClipboardPaste({
|
||||
plainText: "hello world",
|
||||
htmlText: "",
|
||||
});
|
||||
assert.equal(payload.kind, "plain");
|
||||
assert.equal(
|
||||
shouldInterceptResolvedNotePaste({
|
||||
editorMode: "edit",
|
||||
pasteInsideCodeBlock: false,
|
||||
payload,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("structured plain markdown wins over presentation HTML wrappers", () => {
|
||||
const payload = resolveNoteClipboardPaste({
|
||||
plainText: "# Title\n\n- item one",
|
||||
htmlText: "<div><div># Title</div><div>- item one</div></div>",
|
||||
});
|
||||
assert.equal(payload.kind, "markdown");
|
||||
assert.match(payload.text, /^# Title/m);
|
||||
assert.doesNotMatch(payload.text, /\\# Title/);
|
||||
});
|
||||
|
||||
test("TypeScript generics are not treated as HTML islands", () => {
|
||||
assert.equal(plainMarkdownContainsHtml("const values: List<string> = []"), false);
|
||||
assert.equal(plainMarkdownContainsHtml("type M = Map<string, number>"), false);
|
||||
assert.equal(plainMarkdownContainsHtml("fn(): Promise<boolean>"), false);
|
||||
assert.equal(plainMarkdownContainsHtml("Use <span>status</span> here"), true);
|
||||
assert.equal(plainMarkdownContainsHtml('<img src="https://x.com/a.png" />'), true);
|
||||
assert.equal(plainMarkdownContainsHtml("<div>block</div>"), true);
|
||||
});
|
||||
|
||||
test("hard-break trailing spaces are kept outside code", () => {
|
||||
const source = "line one \nline two\n\n\nline three";
|
||||
const md = normalizePastedNoteMarkdown(source);
|
||||
assert.match(md, /line one {2}\nline two/);
|
||||
assert.doesNotMatch(md, /\n{3,}/);
|
||||
});
|
||||
|
||||
test("http and protocol-relative image src normalize to https", () => {
|
||||
assert.equal(
|
||||
serializeSafeHtmlImage({ src: "http://example.com/a.png", alt: "a" }),
|
||||
"",
|
||||
);
|
||||
assert.equal(
|
||||
serializeSafeHtmlImage({ src: "//cdn.example.com/a.png", alt: "cdn" }),
|
||||
"",
|
||||
);
|
||||
});
|
||||
|
||||
test("image attributes decode entities before re-serialize", () => {
|
||||
const md = convertHtmlImgTagToMarkdownOrHtml(
|
||||
'<img width="100" alt="A & B" src="https://example.com/a.png?x=1&y=2" />',
|
||||
);
|
||||
assert.match(md, /alt="A & B"/);
|
||||
assert.match(md, /src="https:\/\/example\.com\/a\.png\?x=1&y=2"/);
|
||||
assert.doesNotMatch(md, /&amp;/);
|
||||
});
|
||||
|
||||
test("img alt with > inside quotes is not truncated", () => {
|
||||
const md = convertHtmlImgTagToMarkdownOrHtml(
|
||||
'<img alt="A > B" src="https://example.com/a.png" width="20" />',
|
||||
);
|
||||
assert.match(md, /alt="A > B"/);
|
||||
assert.match(md, /src="https:\/\/example\.com\/a\.png"/);
|
||||
assert.match(md, /width="20"/);
|
||||
});
|
||||
|
||||
test("fenced code with ](url) and <img> examples is not rewritten", () => {
|
||||
const source = [
|
||||
"# Doc",
|
||||
"",
|
||||
"```md",
|
||||
"](https://example.com)",
|
||||
'<img src="https://example.com/x.png" />',
|
||||
"```",
|
||||
"",
|
||||
"Use `<span>status</span>` inline.",
|
||||
].join("\n");
|
||||
const md = normalizePastedNoteMarkdown(source);
|
||||
assert.match(md, /```md\n\]\(https:\/\/example\.com\)/);
|
||||
assert.match(md, /<img src="https:\/\/example\.com\/x\.png" \/>/);
|
||||
assert.match(md, /`<span>status<\/span>`/);
|
||||
});
|
||||
|
||||
test("nested same-tag HTML islands convert without truncating outer close", () => {
|
||||
const md = convertHtmlIslandsInMarkdown(
|
||||
'<div><div>inner</div><p>after</p></div>\n\n# Done',
|
||||
);
|
||||
assert.match(md, /inner/);
|
||||
assert.match(md, /after/);
|
||||
assert.match(md, /# Done/);
|
||||
});
|
||||
|
||||
test("decodeHtmlEntities ignores out-of-range numeric entities", () => {
|
||||
assert.equal(decodeHtmlEntities("ok A end"), "ok A end");
|
||||
assert.equal(decodeHtmlEntities("bad � keep"), "bad � keep");
|
||||
assert.equal(decodeHtmlEntities("bad � keep"), "bad � keep");
|
||||
assert.doesNotThrow(() => decodeHtmlEntities("��"));
|
||||
});
|
||||
|
||||
test("serializeSafeHtmlImage angles destinations that contain spaces", () => {
|
||||
assert.equal(
|
||||
serializeSafeHtmlImage({ src: "images/company logo.png", alt: "logo" }),
|
||||
"",
|
||||
);
|
||||
});
|
||||
|
||||
test("indented code HTML samples are not converted as islands", () => {
|
||||
const source = [
|
||||
"Intro",
|
||||
"",
|
||||
" <img src=\"https://example.com/code.png\" />",
|
||||
"",
|
||||
'<img src="https://example.com/real.png" />',
|
||||
].join("\n");
|
||||
const md = convertHtmlIslandsInMarkdown(source);
|
||||
assert.match(md, / {4}<img src="https:\/\/example\.com\/code\.png" \/>/);
|
||||
assert.match(md, /!\[\]\(https:\/\/example\.com\/real\.png\)|src="https:\/\/example\.com\/real\.png"/);
|
||||
});
|
||||
|
||||
test("linked badge examples inside fenced code are not rewritten", () => {
|
||||
const source = [
|
||||
"```md",
|
||||
"[](http://example.com)",
|
||||
"```",
|
||||
"",
|
||||
"[](http://example.com)",
|
||||
].join("\n");
|
||||
const md = normalizePastedNoteMarkdown(source);
|
||||
assert.match(md, /```md\n\[!\[shield\]\(http:\/\/img\.shields\.io/);
|
||||
assert.match(md, /!\[live\]\(https:\/\/img\.shields\.io/);
|
||||
});
|
||||
|
||||
test("img getAttr prefers real src over data-src", () => {
|
||||
const md = convertHtmlImgTagToMarkdownOrHtml(
|
||||
'<img data-src="https://lazy.example/x.png" src="https://real.example/y.png" alt="pic" />',
|
||||
);
|
||||
assert.match(md, /real\.example\/y\.png/);
|
||||
assert.doesNotMatch(md, /lazy\.example/);
|
||||
});
|
||||
|
||||
test("normalizeNotePublicAssetPaths leaves public/ samples inside code alone", () => {
|
||||
const source = [
|
||||
"See `public/icon.png` and:",
|
||||
"",
|
||||
"```md",
|
||||
"",
|
||||
"```",
|
||||
"",
|
||||
"",
|
||||
].join("\n");
|
||||
const md = normalizeNotePublicAssetPaths(source);
|
||||
assert.match(md, /`public\/icon\.png`/);
|
||||
assert.match(md, /```md\n!\[x\]\(public\/icon\.png\)/);
|
||||
assert.match(md, /!\[live\]\(\/icon\.png\)/);
|
||||
});
|
||||
|
||||
test("maskCodeRegions covers indented and blockquote fences", () => {
|
||||
const source = [
|
||||
" ```md",
|
||||
" - [ ] fake",
|
||||
" ```",
|
||||
"",
|
||||
"> ```",
|
||||
"> - [ ] quoted-fake",
|
||||
"> ```",
|
||||
"",
|
||||
"- [ ] real",
|
||||
].join("\n");
|
||||
const { text } = maskCodeRegions(source);
|
||||
assert.doesNotMatch(text, /- \[ \] fake/);
|
||||
assert.doesNotMatch(text, /- \[ \] quoted-fake/);
|
||||
assert.match(text, /- \[ \] real/);
|
||||
});
|
||||
|
||||
test("maskCodeRegions does not hide nested list tasks as indented code", () => {
|
||||
const source = [
|
||||
"- parent",
|
||||
" - [ ] child",
|
||||
"- [ ] later",
|
||||
"",
|
||||
" plain indented code",
|
||||
].join("\n");
|
||||
const { text } = maskCodeRegions(source);
|
||||
assert.match(text, / {4}- \[ \] child/);
|
||||
assert.match(text, /- \[ \] later/);
|
||||
assert.doesNotMatch(text, /plain indented code/);
|
||||
});
|
||||
|
||||
test("maskCodeRegions sentinels do not collide with user-authored tokens", () => {
|
||||
const source = [
|
||||
"keep @@NETCATTY_MD_CODE_0@@ literal",
|
||||
"",
|
||||
"```",
|
||||
"code body",
|
||||
"```",
|
||||
].join("\n");
|
||||
const mask = maskCodeRegions(source);
|
||||
assert.match(mask.text, /keep @@NETCATTY_MD_CODE_0@@ literal/);
|
||||
assert.notEqual(mask.sentinel, "@@NETCATTY_MD_CODE_");
|
||||
const restored = unmaskCodeRegions(mask.text, mask.slots, mask.sentinel);
|
||||
assert.equal(restored, source);
|
||||
});
|
||||
|
||||
test("linked badge anchors prefer real href over data-href", () => {
|
||||
const md = normalizeLinkedBadgeImages(
|
||||
'<a data-href="https://wrong.example" href="https://right.example"><img src="https://img.example/a.png" alt="a" /></a>',
|
||||
);
|
||||
assert.match(md, /right\.example/);
|
||||
assert.doesNotMatch(md, /wrong\.example/);
|
||||
});
|
||||
|
||||
test("maskCodeRegions accepts longer closing fences", () => {
|
||||
const source = [
|
||||
"```md",
|
||||
"",
|
||||
"- [ ] fake",
|
||||
"````",
|
||||
"",
|
||||
"- [ ] real",
|
||||
].join("\n");
|
||||
const mask = maskCodeRegions(source);
|
||||
assert.doesNotMatch(mask.text, /public\/icon\.png/);
|
||||
assert.doesNotMatch(mask.text, /- \[ \] fake/);
|
||||
assert.match(mask.text, /- \[ \] real/);
|
||||
assert.equal(unmaskCodeRegions(mask.text, mask.slots, mask.sentinel), source);
|
||||
});
|
||||
|
||||
test("maskCodeRegions keeps info strings starting with the other fence char", () => {
|
||||
// Opening is three backticks; info may start with ~ without lengthening the fence.
|
||||
const source = ["```~tip", "", "```", "", "after"].join("\n");
|
||||
const mask = maskCodeRegions(source);
|
||||
assert.doesNotMatch(mask.text, /public\/a\.png/);
|
||||
assert.match(mask.text, /after/);
|
||||
assert.equal(unmaskCodeRegions(mask.text, mask.slots, mask.sentinel), source);
|
||||
});
|
||||
|
||||
test("maskCodeRegions masks multi-backtick spans with inner shorter runs", () => {
|
||||
const source = "Use ``a `  b`` end";
|
||||
const mask = maskCodeRegions(source);
|
||||
assert.doesNotMatch(mask.text, /public\/a\.png/);
|
||||
assert.match(mask.text, / end$/);
|
||||
assert.equal(unmaskCodeRegions(mask.text, mask.slots, mask.sentinel), source);
|
||||
});
|
||||
|
||||
test("maskCodeRegions masks standalone indented task samples as code", () => {
|
||||
const source = [" - [ ] sample", "", "- [ ] real"].join("\n");
|
||||
const mask = maskCodeRegions(source);
|
||||
assert.doesNotMatch(mask.text, /- \[ \] sample/);
|
||||
assert.match(mask.text, /- \[ \] real/);
|
||||
});
|
||||
|
||||
test("normalizeLinkedBadgeImages preserves angled destinations with spaces", () => {
|
||||
const md = normalizeLinkedBadgeImages(
|
||||
"[](https://example.com)",
|
||||
);
|
||||
assert.match(md, /company logo\.png|company%20logo\.png|company logo/);
|
||||
assert.match(md, /example\.com/);
|
||||
assert.doesNotMatch(md, /<images\/company(?! logo)/);
|
||||
});
|
||||
|
||||
test("maskCodeRegions masks multi-level blockquote fences", () => {
|
||||
const source = [
|
||||
"> > ~~~md",
|
||||
"> > ",
|
||||
"> > ~~~",
|
||||
"",
|
||||
"after",
|
||||
].join("\n");
|
||||
const mask = maskCodeRegions(source);
|
||||
assert.doesNotMatch(mask.text, /public\/a\.png/);
|
||||
assert.match(mask.text, /after/);
|
||||
});
|
||||
|
||||
test("extractBalancedHtmlElement handles raw-text script bodies with <", () => {
|
||||
const source = '<script>if (a < b) alert(1)</script>\n# after';
|
||||
const extracted = extractBalancedHtmlElement(source, 0);
|
||||
assert.ok(extracted);
|
||||
assert.equal(extracted?.tag, "script");
|
||||
assert.match(extracted?.full ?? "", /if \(a < b\)/);
|
||||
assert.equal(source.slice(extracted?.end ?? 0), "\n# after");
|
||||
});
|
||||
909
domain/notes/clipboardPaste.ts
Normal file
909
domain/notes/clipboardPaste.ts
Normal file
@@ -0,0 +1,909 @@
|
||||
/**
|
||||
* Note clipboard paste policy (domain — pure, no React).
|
||||
*
|
||||
* Product policy (lossy but clean — not a GitHub README clone):
|
||||
* - Prefer structured text/plain Markdown when present
|
||||
* - Otherwise HTML → Markdown via Turndown (+ island conversion)
|
||||
* - Linked badges stay as images (tight [](href) or <a><img>)
|
||||
* - Image dimensions preserved; CSS scales large screenshots in the panel
|
||||
* - Centered blocks → <div align="center"> for MDX GenericHTML
|
||||
*/
|
||||
|
||||
import TurndownService from "turndown";
|
||||
import { gfm } from "turndown-plugin-gfm";
|
||||
|
||||
export type NoteClipboardPasteKind =
|
||||
| "markdown"
|
||||
| "html-converted"
|
||||
| "plain"
|
||||
| "empty";
|
||||
|
||||
export type NoteClipboardPastePayload = {
|
||||
text: string;
|
||||
kind: NoteClipboardPasteKind;
|
||||
};
|
||||
|
||||
const PASTED_MARKDOWN_PATTERNS = [
|
||||
/^ {0,3}#{1,6}\s+\S/m,
|
||||
/^ {0,3}(?:[-+*]|\d+[.)])\s+\S/m,
|
||||
/^ {0,3}>\s+\S/m,
|
||||
/^ {0,3}(?:```|~~~)/m,
|
||||
/^ {0,3}[-*_](?:\s*[-*_]){2,}\s*$/m,
|
||||
/^ {0,3}\|?.+\|.+\n {0,3}\|?\s*:?-{3,}:?\s*(?:\|\s*:?-{3,}:?\s*)+\|?\s*$/m,
|
||||
/(^|[^!])\[[^\]\n]+\]\([^) \n]+(?:\s+"[^"\n]*")?\)/,
|
||||
/(^|[\s([{])(?:\*\*|__)\S[\s\S]*?\S(?:\*\*|__)(?=$|[\s\])}.,;:!?])/,
|
||||
/(^|[\s([{])`[^`\n]+`(?=$|[\s\])}.,;:!?])/,
|
||||
/!\[[^\]]*\]\([^)\s]+\)/,
|
||||
/<img\b/i,
|
||||
];
|
||||
|
||||
/**
|
||||
* Bare known HTML element names. Type tokens like `string` in `List<string>` are
|
||||
* intentionally absent so TypeScript generics are not treated as markup islands.
|
||||
*/
|
||||
const BARE_HTML_TAG_RE =
|
||||
/<\/?(?:a|abbr|address|area|article|aside|audio|b|base|bdi|bdo|blockquote|body|br|button|canvas|caption|cite|code|col|colgroup|data|datalist|dd|del|details|dfn|dialog|div|dl|dt|em|embed|fieldset|figcaption|figure|footer|form|h[1-6]|head|header|hgroup|hr|html|i|iframe|img|input|ins|kbd|label|legend|li|link|main|map|mark|menu|meta|meter|nav|noscript|object|ol|optgroup|option|output|p|param|picture|pre|progress|q|rp|rt|ruby|s|samp|script|section|select|slot|small|source|span|strong|style|sub|summary|sup|svg|table|tbody|td|template|textarea|tfoot|th|thead|time|title|tr|track|u|ul|var|video|wbr)(?:\s|\/|>)/i;
|
||||
|
||||
/** True when plain clipboard text already looks like structured markdown source. */
|
||||
export const shouldInsertClipboardTextAsMarkdown = (text: string): boolean => {
|
||||
const markdown = text.replace(/\r\n?/g, "\n").trim();
|
||||
if (!markdown) return false;
|
||||
return PASTED_MARKDOWN_PATTERNS.some((pattern) => pattern.test(markdown));
|
||||
};
|
||||
|
||||
/** True when clipboard HTML is worth converting (not empty / not a lone meta tag). */
|
||||
export const looksLikeClipboardHtml = (html: string): boolean => {
|
||||
const trimmed = html.trim();
|
||||
if (!trimmed) return false;
|
||||
if (!/<[a-zA-Z!/?]/.test(trimmed)) return false;
|
||||
const withoutMeta = trimmed
|
||||
.replace(/<!--[\s\S]*?-->/g, "")
|
||||
.replace(/<meta\b[^>]*>/gi, "")
|
||||
.replace(/<\/?(?:html|head|body)\b[^>]*>/gi, "")
|
||||
.trim();
|
||||
return withoutMeta.length > 0;
|
||||
};
|
||||
|
||||
/**
|
||||
* True when plain text embeds real HTML tags (not TS generics like List<string>).
|
||||
*
|
||||
* Heuristics:
|
||||
* - comments / doctype → HTML
|
||||
* - closing tags → HTML
|
||||
* - open tags with attributes or void self-close → HTML
|
||||
* - bare known HTML element names only (`<div>`, `<span>`) → HTML
|
||||
*/
|
||||
export const plainMarkdownContainsHtml = (text: string): boolean => {
|
||||
if (/<!--/.test(text) || /<!doctype\b/i.test(text)) return true;
|
||||
if (/<\/[a-z][a-z0-9:-]*\s*>/i.test(text)) return true;
|
||||
if (/<[a-z][a-z0-9:-]*\s+[^>]*>/i.test(text)) return true;
|
||||
if (/<[a-z][a-z0-9:-]*\s*\/>/i.test(text)) return true;
|
||||
return BARE_HTML_TAG_RE.test(text);
|
||||
};
|
||||
|
||||
/**
|
||||
* True when the payload is primarily an HTML document (browser / Word / GitHub
|
||||
* rich clipboard), not markdown-with-a-few-tags.
|
||||
*/
|
||||
export const isPrimarilyHtmlDocument = (html: string): boolean => {
|
||||
const trimmed = html.trim();
|
||||
if (!trimmed) return false;
|
||||
if (/<!--StartFragment-->/i.test(trimmed)) return true;
|
||||
if (/<\s*html[\s>]/i.test(trimmed)) return true;
|
||||
if (/<\s*body[\s>]/i.test(trimmed)) return true;
|
||||
const withoutTags = trimmed.replace(/<[^>]+>/g, "").replace(/\s+/g, " ").trim();
|
||||
const tagChars = (trimmed.match(/<[^>]+>/g) ?? []).join("").length;
|
||||
if (tagChars === 0) return false;
|
||||
if (withoutTags.length === 0) return true;
|
||||
return tagChars >= withoutTags.length * 0.35;
|
||||
};
|
||||
|
||||
let turndownSingleton: TurndownService | null = null;
|
||||
|
||||
const getTurndown = (): TurndownService => {
|
||||
if (turndownSingleton) return turndownSingleton;
|
||||
const service = new TurndownService({
|
||||
headingStyle: "atx",
|
||||
hr: "---",
|
||||
bulletListMarker: "-",
|
||||
codeBlockStyle: "fenced",
|
||||
emDelimiter: "*",
|
||||
strongDelimiter: "**",
|
||||
linkStyle: "inlined",
|
||||
preformattedCode: true,
|
||||
});
|
||||
service.use(gfm);
|
||||
service.addRule("stripEmptyAnchors", {
|
||||
filter: (node) => (
|
||||
node.nodeName === "A"
|
||||
&& !(node as HTMLElement).getAttribute("href")
|
||||
&& !(node.textContent ?? "").trim()
|
||||
),
|
||||
replacement: () => "",
|
||||
});
|
||||
service.addRule("skipDataImages", {
|
||||
filter: (node) => (
|
||||
node.nodeName === "IMG"
|
||||
&& ((node as HTMLImageElement).getAttribute("src") ?? "").startsWith("data:")
|
||||
),
|
||||
replacement: () => "",
|
||||
});
|
||||
service.addRule("keepCenteredBlocks", {
|
||||
filter: (node) => isCenteredBlockElement(node as HTMLElement),
|
||||
replacement: (content, node) => {
|
||||
let inner = content.trim();
|
||||
const tag = (node as HTMLElement).nodeName.toLowerCase();
|
||||
const heading = /^h([1-6])$/.exec(tag);
|
||||
if (heading && inner && !/^#{1,6}\s/m.test(inner)) {
|
||||
inner = `${"#".repeat(Number(heading[1]))} ${inner}`;
|
||||
}
|
||||
return wrapCenteredMarkdown(inner);
|
||||
},
|
||||
});
|
||||
service.addRule("imagesForNotes", {
|
||||
filter: "img",
|
||||
replacement: (_content, node) => {
|
||||
const el = node as HTMLImageElement;
|
||||
const src = (el.getAttribute("src") ?? "").trim();
|
||||
if (!src || src.startsWith("data:")) return "";
|
||||
const html = serializeSafeHtmlImage({
|
||||
src,
|
||||
alt: el.getAttribute("alt") ?? "",
|
||||
title: el.getAttribute("title") ?? undefined,
|
||||
width: el.getAttribute("width") ?? undefined,
|
||||
height: el.getAttribute("height") ?? undefined,
|
||||
});
|
||||
return html ? `\n\n${html}\n\n` : "";
|
||||
},
|
||||
});
|
||||
turndownSingleton = service;
|
||||
return service;
|
||||
};
|
||||
|
||||
const CENTERED_BLOCK_TAGS = new Set([
|
||||
"P", "DIV", "H1", "H2", "H3", "H4", "H5", "H6", "SECTION", "HEADER",
|
||||
]);
|
||||
|
||||
export const isCenteredBlockElement = (node: HTMLElement | Element | null | undefined): boolean => {
|
||||
if (!node || !("nodeName" in node)) return false;
|
||||
if (!CENTERED_BLOCK_TAGS.has(node.nodeName)) return false;
|
||||
const el = node as HTMLElement;
|
||||
const align = (el.getAttribute?.("align") ?? "").trim().toLowerCase();
|
||||
if (align === "center") return true;
|
||||
const style = el.getAttribute?.("style") ?? "";
|
||||
if (/text-align\s*:\s*center/i.test(style)) return true;
|
||||
return false;
|
||||
};
|
||||
|
||||
export const htmlOpenTagIsCentered = (openTagOrFull: string): boolean => {
|
||||
if (/\balign\s*=\s*(?:"|')?center(?:"|')?/i.test(openTagOrFull)) return true;
|
||||
if (/text-align\s*:\s*center/i.test(openTagOrFull)) return true;
|
||||
// MDX / Tailwind class-based centering
|
||||
if (/\bclass(?:Name)?\s*=\s*["'][^"']*\btext-center\b/i.test(openTagOrFull)) return true;
|
||||
return false;
|
||||
};
|
||||
|
||||
export const wrapCenteredMarkdown = (inner: string): string => {
|
||||
const body = inner.replace(/\r\n?/g, "\n").trim();
|
||||
if (!body) return "";
|
||||
if (/^<div\s+align="center">/i.test(body) && /<\/div>\s*$/i.test(body)) {
|
||||
return `\n\n${body}\n\n`;
|
||||
}
|
||||
return `\n\n<div align="center">\n\n${body}\n\n</div>\n\n`;
|
||||
};
|
||||
|
||||
/** Unicode scalar values accepted by String.fromCodePoint (rejects surrogates-only overflow). */
|
||||
const isValidUnicodeCodePoint = (code: number): boolean => (
|
||||
Number.isFinite(code)
|
||||
&& Number.isInteger(code)
|
||||
&& code >= 0
|
||||
&& code <= 0x10FFFF
|
||||
);
|
||||
|
||||
const codePointFromEntity = (code: number, fallback: string): string => (
|
||||
isValidUnicodeCodePoint(code) ? String.fromCodePoint(code) : fallback
|
||||
);
|
||||
|
||||
/** Decode common HTML entities (once) before re-escaping on serialize. */
|
||||
export const decodeHtmlEntities = (value: string): string => (
|
||||
value
|
||||
.replace(/ /gi, " ")
|
||||
.replace(/"/gi, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/'/gi, "'")
|
||||
.replace(/</gi, "<")
|
||||
.replace(/>/gi, ">")
|
||||
.replace(/&#(\d+);/g, (_, n: string) => codePointFromEntity(Number(n), _))
|
||||
.replace(/&#x([0-9a-f]+);/gi, (_, hex: string) => (
|
||||
codePointFromEntity(Number.parseInt(hex, 16), _)
|
||||
))
|
||||
// & last so we don't re-expand decoded entities
|
||||
.replace(/&/gi, "&")
|
||||
);
|
||||
|
||||
/**
|
||||
* Normalize image src for note storage / in-app load.
|
||||
* - https ok
|
||||
* - http → https (CSP blocks http images)
|
||||
* - //host → https://host (app:// base would otherwise break)
|
||||
* - Vite/Electron: files under repo `public/` are served at site root, so
|
||||
* `public/icon.png` / `/public/icon.png` → `/icon.png` (avoids Vite
|
||||
* "use /icon.png instead of /public/icon.png" warnings)
|
||||
* - other relative paths kept as-is (`./docs/...`, `/distro/foo.svg`)
|
||||
* - data:/javascript: rejected
|
||||
*/
|
||||
export const normalizeImageSrc = (src: string): string | null => {
|
||||
let trimmed = src.trim();
|
||||
if (!trimmed) return null;
|
||||
if (trimmed.startsWith("data:")) return null;
|
||||
if (/^javascript:/i.test(trimmed)) return null;
|
||||
if (trimmed.startsWith("//") && /^\/\/[^/\s]/.test(trimmed)) {
|
||||
trimmed = `https:${trimmed}`;
|
||||
}
|
||||
if (/^http:\/\//i.test(trimmed)) {
|
||||
trimmed = `https://${trimmed.slice("http://".length)}`;
|
||||
}
|
||||
if (/^https:\/\//i.test(trimmed)) return trimmed;
|
||||
// public/ is the Vite static root — never request /public/...
|
||||
if (/^\/?public\//i.test(trimmed)) {
|
||||
return `/${trimmed.replace(/^\/?public\//i, "")}`;
|
||||
}
|
||||
if (trimmed.startsWith("/") || trimmed.startsWith("./") || trimmed.startsWith("../")) {
|
||||
return trimmed;
|
||||
}
|
||||
// Bare relative like `public/icon.png` already handled; `docs/foo.png` keep.
|
||||
if (!/^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(trimmed)) return trimmed;
|
||||
return null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Rewrite Vite public-dir URLs in markdown/HTML so the browser never requests /public/*.
|
||||
* /public/foo.png → /foo.png
|
||||
* public/foo.png → /foo.png
|
||||
* Only outside fenced/indented/inline code so docs samples stay literal.
|
||||
*/
|
||||
export const normalizeNotePublicAssetPaths = (markdown: string): string => (
|
||||
mapOutsideCode(markdown, (plain) => {
|
||||
let body = plain;
|
||||
body = body.replace(/((?:^|\s)src\s*=\s*["'])\/?public\//gi, "$1/");
|
||||
body = body.replace(/\]\(\s*\/?public\//gi, "](/");
|
||||
return body;
|
||||
})
|
||||
);
|
||||
|
||||
const escapeHtmlAttr = (value: string): string => (
|
||||
value
|
||||
.replace(/&/g, "&")
|
||||
.replace(/"/g, """)
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
);
|
||||
|
||||
export const serializeSafeHtmlImage = (input: {
|
||||
src: string;
|
||||
alt?: string;
|
||||
title?: string;
|
||||
width?: string | number | null;
|
||||
height?: string | number | null;
|
||||
}): string => {
|
||||
const src = normalizeImageSrc(input.src ?? "");
|
||||
if (!src) return "";
|
||||
const alt = decodeHtmlEntities(input.alt ?? "").replace(/[[\]]/g, "");
|
||||
const title = decodeHtmlEntities(input.title?.trim() || "");
|
||||
const widthRaw = input.width != null ? String(input.width).trim() : "";
|
||||
const heightRaw = input.height != null ? String(input.height).trim() : "";
|
||||
const safeWidth = /^(?:\d+(?:\.\d+)?%?)$/.test(widthRaw) ? widthRaw : "";
|
||||
const safeHeight = /^(?:\d+(?:\.\d+)?%?)$/.test(heightRaw) ? heightRaw : "";
|
||||
|
||||
if (!safeWidth && !safeHeight) {
|
||||
const titlePart = title ? ` "${title.replace(/"/g, '\\"')}"` : "";
|
||||
// CommonMark: destinations with spaces/parens must be <angled> or they break.
|
||||
const destination = /[\s()]/.test(src) ? `<${src.replace(/[<>]/g, "")}>` : src;
|
||||
return ``;
|
||||
}
|
||||
|
||||
const parts = [
|
||||
`src="${escapeHtmlAttr(src)}"`,
|
||||
`alt="${escapeHtmlAttr(alt)}"`,
|
||||
];
|
||||
if (title) parts.push(`title="${escapeHtmlAttr(title)}"`);
|
||||
if (safeWidth) parts.push(`width="${escapeHtmlAttr(safeWidth)}"`);
|
||||
if (safeHeight) parts.push(`height="${escapeHtmlAttr(safeHeight)}"`);
|
||||
return `<img ${parts.join(" ")} />`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Collapse consecutive blank lines outside fenced/indented code, but keep
|
||||
* Turndown hard-breaks (two trailing spaces before \n) and blank lines in code.
|
||||
*/
|
||||
export const trimBlankLinesOutsideCode = (value: string): string => {
|
||||
const regions = maskCodeRegions(value.replace(/\r\n?/g, "\n"));
|
||||
let body = regions.text;
|
||||
// Collapse 3+ blank lines → 2, without eating hard-break spaces on content lines.
|
||||
body = body.replace(/\n{3,}/g, "\n\n");
|
||||
body = body.replace(/^\n+/, "").replace(/\n+$/, "");
|
||||
// Strip trailing spaces on blank-only lines, but keep " \n" hard breaks on non-empty lines.
|
||||
body = body.replace(/^[ \t]+$/gm, "");
|
||||
return unmaskCodeRegions(body, regions.slots, regions.sentinel);
|
||||
};
|
||||
|
||||
const trimBlankLines = trimBlankLinesOutsideCode;
|
||||
|
||||
const turndownFragment = (html: string): string => {
|
||||
try {
|
||||
return getTurndown().turndown(html);
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
};
|
||||
|
||||
/** Scan an HTML tag end respecting quoted attribute values (allows `>` inside quotes). */
|
||||
export const findHtmlTagEnd = (source: string, start: number): number => {
|
||||
if (source[start] !== "<") return -1;
|
||||
let i = start + 1;
|
||||
let quote: '"' | "'" | null = null;
|
||||
while (i < source.length) {
|
||||
const ch = source[i];
|
||||
if (quote) {
|
||||
if (ch === quote) quote = null;
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if (ch === '"' || ch === "'") {
|
||||
quote = ch;
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if (ch === ">") return i;
|
||||
i += 1;
|
||||
}
|
||||
return -1;
|
||||
};
|
||||
|
||||
/** Parse a single <img …> tag (quote-aware) into safe markdown/HTML. */
|
||||
export const convertHtmlImgTagToMarkdownOrHtml = (imgTag: string): string => {
|
||||
const trimmed = imgTag.trim();
|
||||
if (!/^<img\b/i.test(trimmed)) return turndownFragment(imgTag).trim();
|
||||
const end = findHtmlTagEnd(trimmed, 0);
|
||||
if (end < 0) return "";
|
||||
const open = trimmed.slice(0, end + 1);
|
||||
// Only convert a pure img tag (optional trailing whitespace), not following debris.
|
||||
if (trimmed.slice(end + 1).trim()) {
|
||||
// Fall back: try only the tag portion
|
||||
}
|
||||
const attrBlob = open.replace(/^<img\b/i, "").replace(/\/?>$/, "");
|
||||
const getAttr = (name: string): string => {
|
||||
// Require a real attribute boundary so `data-src` does not match `src`.
|
||||
const re = new RegExp(
|
||||
`(?:^|[\\s"'/])${name}\\s*=\\s*(?:"([^"]*)"|'([^']*)'|([^\\s>]+))`,
|
||||
"i",
|
||||
);
|
||||
const m = re.exec(attrBlob);
|
||||
const raw = (m?.[1] ?? m?.[2] ?? m?.[3] ?? "").trim();
|
||||
return decodeHtmlEntities(raw);
|
||||
};
|
||||
return serializeSafeHtmlImage({
|
||||
src: getAttr("src"),
|
||||
alt: getAttr("alt"),
|
||||
title: getAttr("title") || undefined,
|
||||
width: getAttr("width") || undefined,
|
||||
height: getAttr("height") || undefined,
|
||||
});
|
||||
};
|
||||
|
||||
const extractMarkdownImageAlt = (imageChunk: string): string => {
|
||||
const md = /!\[([^\]]*)\]/.exec(imageChunk);
|
||||
if (md) return (md[1] || "link").trim() || "link";
|
||||
const htmlAlt = /alt\s*=\s*(?:"([^"]*)"|'([^']*)')/i.exec(imageChunk);
|
||||
if (htmlAlt) {
|
||||
return decodeHtmlEntities((htmlAlt[1] ?? htmlAlt[2] ?? "link").trim()) || "link";
|
||||
}
|
||||
return "link";
|
||||
};
|
||||
|
||||
export const normalizeLinkedBadgeImages = (markdown: string): string => {
|
||||
let body = markdown.replace(/\r\n?/g, "\n");
|
||||
|
||||
body = body.replace(
|
||||
/\[\s*!\[[^\]]*\]\(([^)]+)\)\s*\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g,
|
||||
(full, imgSrc: string, href: string) => {
|
||||
const alt = extractMarkdownImageAlt(full);
|
||||
// Support angled destinations with spaces: <images/company logo.png>
|
||||
const rawDest = (imgSrc || "").trim();
|
||||
let dest = rawDest;
|
||||
if (rawDest.startsWith("<")) {
|
||||
const end = rawDest.indexOf(">");
|
||||
dest = end > 0 ? rawDest.slice(1, end) : rawDest.slice(1);
|
||||
} else {
|
||||
dest = rawDest.split(/\s+/)[0] ?? "";
|
||||
}
|
||||
const src = normalizeImageSrc(dest);
|
||||
if (!src) return `[${alt}](${href})`;
|
||||
return `[](${href})`;
|
||||
},
|
||||
);
|
||||
|
||||
body = body.replace(
|
||||
/\[\s*(<img\b[\s\S]*?>)\s*\]\(([^)\s]+)(?:\s+"[^"]*")?\)/gi,
|
||||
(_full, imgTag: string, href: string) => {
|
||||
const end = findHtmlTagEnd(String(imgTag).trim(), 0);
|
||||
const tag = end >= 0 ? String(imgTag).trim().slice(0, end + 1) : String(imgTag).trim();
|
||||
const safeImg = convertHtmlImgTagToMarkdownOrHtml(tag);
|
||||
if (!safeImg) return "";
|
||||
if (safeImg.startsWith("<img")) {
|
||||
return `<a href="${escapeHtmlAttr(href)}">${safeImg}</a>`;
|
||||
}
|
||||
const m = /!\[([^\]]*)\]\(([^)\s]+)\)/.exec(safeImg);
|
||||
if (m) return `[![${m[1]}](${m[2]})](${href})`;
|
||||
return `[${extractMarkdownImageAlt(safeImg)}](${href})`;
|
||||
},
|
||||
);
|
||||
|
||||
body = body.replace(
|
||||
/<a\b([^>]*)>\s*(<img\b[\s\S]*?>)\s*<\/a>/gi,
|
||||
(full, aAttrs: string, imgTag: string) => {
|
||||
// Require a real attribute boundary so `data-href` does not match `href`.
|
||||
const hrefMatch = /(?:^|[\s"'/])href\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))/i.exec(aAttrs);
|
||||
const href = decodeHtmlEntities(
|
||||
(hrefMatch?.[1] ?? hrefMatch?.[2] ?? hrefMatch?.[3] ?? "").trim(),
|
||||
);
|
||||
if (!href || /^javascript:/i.test(href)) {
|
||||
return convertHtmlImgTagToMarkdownOrHtml(imgTag) || "";
|
||||
}
|
||||
const end = findHtmlTagEnd(String(imgTag).trim(), 0);
|
||||
const tag = end >= 0 ? String(imgTag).trim().slice(0, end + 1) : String(imgTag).trim();
|
||||
const safeImg = convertHtmlImgTagToMarkdownOrHtml(tag);
|
||||
if (!safeImg) return "";
|
||||
if (safeImg.startsWith("<img")) {
|
||||
return `<a href="${escapeHtmlAttr(href)}">${safeImg}</a>`;
|
||||
}
|
||||
const m = /!\[([^\]]*)\]\(([^)\s]+)\)/.exec(safeImg);
|
||||
if (m) return `[![${m[1]}](${m[2]})](${href})`;
|
||||
return `[${extractMarkdownImageAlt(full)}](${href})`;
|
||||
},
|
||||
);
|
||||
|
||||
return body;
|
||||
};
|
||||
|
||||
export type CodeMask = {
|
||||
text: string;
|
||||
slots: string[];
|
||||
/** Unique prefix for this pass so user-authored sentinel text cannot collide. */
|
||||
sentinel: string;
|
||||
};
|
||||
|
||||
const escapeRegExp = (value: string): string => (
|
||||
value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
|
||||
);
|
||||
|
||||
const chooseCodeMaskSentinel = (markdown: string): string => {
|
||||
let n = 0;
|
||||
let sentinel = "@@NETCATTY_MD_CODE_";
|
||||
while (markdown.includes(sentinel)) {
|
||||
n += 1;
|
||||
sentinel = `@@NETCATTY_MD_CODE_S${n}_`;
|
||||
}
|
||||
return sentinel;
|
||||
};
|
||||
|
||||
/**
|
||||
* Mask GFM fenced blocks. Closing fence may be longer than the opener
|
||||
* (CommonMark: close with same char and length >= open).
|
||||
* Fence run must be homogeneous (` or ~), not a mixed character class.
|
||||
*/
|
||||
const maskFencedCodeBlocks = (
|
||||
markdown: string,
|
||||
stash: (chunk: string) => string,
|
||||
): string => {
|
||||
const lines = markdown.split("\n");
|
||||
const out: string[] = [];
|
||||
let i = 0;
|
||||
// Any blockquote depth, then up to 3 spaces, then homogeneous ``` or ~~~ (3+).
|
||||
const openRe = /^((?:[ \t]{0,3}>[ \t]?)*)[ \t]{0,3}(`{3,}|~{3,})(.*)$/;
|
||||
const closeRe = /^((?:[ \t]{0,3}>[ \t]?)*)[ \t]{0,3}(`{3,}|~{3,})[ \t]*$/;
|
||||
|
||||
while (i < lines.length) {
|
||||
const openMatch = openRe.exec(lines[i] ?? "");
|
||||
if (!openMatch) {
|
||||
out.push(lines[i] ?? "");
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
const fence = openMatch[2] ?? "";
|
||||
const fenceChar = fence[0] ?? "`";
|
||||
const fenceLen = fence.length;
|
||||
// Info string may not contain the fence character (CommonMark).
|
||||
const info = openMatch[3] ?? "";
|
||||
if (info.includes(fenceChar)) {
|
||||
out.push(lines[i] ?? "");
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const block: string[] = [lines[i] ?? ""];
|
||||
i += 1;
|
||||
while (i < lines.length) {
|
||||
const line = lines[i] ?? "";
|
||||
const closeMatch = closeRe.exec(line);
|
||||
if (
|
||||
closeMatch
|
||||
&& (closeMatch[2]?.[0] ?? "") === fenceChar
|
||||
&& (closeMatch[2]?.length ?? 0) >= fenceLen
|
||||
) {
|
||||
block.push(line);
|
||||
i += 1;
|
||||
break;
|
||||
}
|
||||
block.push(line);
|
||||
i += 1;
|
||||
}
|
||||
// Unclosed fence: still mask through EOF so trailing samples stay protected.
|
||||
out.push(stash(block.join("\n")));
|
||||
}
|
||||
return out.join("\n");
|
||||
};
|
||||
|
||||
const isMarkdownListLine = (line: string): boolean => (
|
||||
/^(?:[ \t]{0,3}>[ \t]?)*[ \t]*(?:[-*+]|\d+[.)])[ \t]+/.test(line)
|
||||
);
|
||||
|
||||
/**
|
||||
* Mask 4-space / tab indented code. Nested list items under a preceding list
|
||||
* line stay unmasked so task indices match the preview DOM; standalone
|
||||
* ` - [ ] sample` at document root remains code (no checkbox in preview).
|
||||
*/
|
||||
const maskIndentedCodeBlocks = (
|
||||
markdown: string,
|
||||
stash: (chunk: string) => string,
|
||||
): string => {
|
||||
const lines = markdown.split("\n");
|
||||
const out: string[] = [];
|
||||
let i = 0;
|
||||
const isBlank = (line: string) => /^[ \t]*$/.test(line);
|
||||
const isIndented = (line: string) => /^(?: {4}|\t)/.test(line);
|
||||
|
||||
while (i < lines.length) {
|
||||
const line = lines[i] ?? "";
|
||||
if (!isIndented(line)) {
|
||||
out.push(line);
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
let prev = i - 1;
|
||||
while (prev >= 0 && isBlank(lines[prev] ?? "")) prev -= 1;
|
||||
const inListContext = prev >= 0 && isMarkdownListLine(lines[prev] ?? "");
|
||||
if (inListContext && isMarkdownListLine(line)) {
|
||||
out.push(line);
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const block: string[] = [];
|
||||
while (i < lines.length && isIndented(lines[i] ?? "")) {
|
||||
const cur = lines[i] ?? "";
|
||||
// Nested list continuation inside an open list stays as list, not code.
|
||||
if (inListContext && isMarkdownListLine(cur)) {
|
||||
if (block.length > 0) out.push(stash(block.join("\n")));
|
||||
block.length = 0;
|
||||
out.push(cur);
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
block.push(cur);
|
||||
i += 1;
|
||||
}
|
||||
if (block.length > 0) out.push(stash(block.join("\n")));
|
||||
}
|
||||
return out.join("\n");
|
||||
};
|
||||
|
||||
/**
|
||||
* Mask CommonMark inline code spans (`…`, `` … ` … ``, etc.).
|
||||
* Content may include shorter backtick runs; close with the same length.
|
||||
*/
|
||||
const maskInlineCodeSpans = (
|
||||
markdown: string,
|
||||
stash: (chunk: string) => string,
|
||||
): string => {
|
||||
let out = "";
|
||||
let i = 0;
|
||||
while (i < markdown.length) {
|
||||
if (markdown[i] !== "`") {
|
||||
out += markdown[i];
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
let j = i;
|
||||
while (j < markdown.length && markdown[j] === "`") j += 1;
|
||||
const n = j - i;
|
||||
// Scan for a closing run of exactly n backticks (not part of a longer run).
|
||||
let k = j;
|
||||
let found = -1;
|
||||
while (k < markdown.length) {
|
||||
// Blank line ends an inline code attempt (CommonMark).
|
||||
if (markdown[k] === "\n" && markdown[k + 1] === "\n") break;
|
||||
if (markdown[k] !== "`") {
|
||||
k += 1;
|
||||
continue;
|
||||
}
|
||||
let m = k;
|
||||
while (m < markdown.length && markdown[m] === "`") m += 1;
|
||||
const run = m - k;
|
||||
if (run === n) {
|
||||
found = m;
|
||||
break;
|
||||
}
|
||||
k = m;
|
||||
}
|
||||
if (found < 0) {
|
||||
out += markdown[i];
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
out += stash(markdown.slice(i, found));
|
||||
i = found;
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
/** Mask fenced (3+ ticks), indented, and inline code so cleanup won't touch them. */
|
||||
export const maskCodeRegions = (markdown: string): CodeMask => {
|
||||
const slots: string[] = [];
|
||||
const sentinel = chooseCodeMaskSentinel(markdown);
|
||||
const stash = (chunk: string): string => {
|
||||
const token = `${sentinel}${slots.length}@@`;
|
||||
slots.push(chunk);
|
||||
return token;
|
||||
};
|
||||
|
||||
let body = maskFencedCodeBlocks(markdown, stash);
|
||||
body = maskIndentedCodeBlocks(body, stash);
|
||||
body = maskInlineCodeSpans(body, stash);
|
||||
|
||||
return { text: body, slots, sentinel };
|
||||
};
|
||||
|
||||
export const unmaskCodeRegions = (
|
||||
text: string,
|
||||
slots: string[],
|
||||
sentinel = "@@NETCATTY_MD_CODE_",
|
||||
): string => {
|
||||
const re = new RegExp(`${escapeRegExp(sentinel)}(\\d+)@@`, "g");
|
||||
return text.replace(re, (_, idx: string) => slots[Number(idx)] ?? "");
|
||||
};
|
||||
|
||||
const stripOrphanLinkClosersOutsideCode = (markdown: string): string => {
|
||||
const { text, slots, sentinel } = maskCodeRegions(markdown);
|
||||
const cleaned = text.replace(/^\s*\]\([^)\n]+\)\s*$/gm, "");
|
||||
return unmaskCodeRegions(cleaned, slots, sentinel);
|
||||
};
|
||||
|
||||
/** Apply a transform only outside code regions. */
|
||||
const mapOutsideCode = (markdown: string, fn: (plain: string) => string): string => {
|
||||
const { text, slots, sentinel } = maskCodeRegions(markdown);
|
||||
return unmaskCodeRegions(fn(text), slots, sentinel);
|
||||
};
|
||||
|
||||
export const normalizePastedNoteMarkdown = (markdown: string): string => {
|
||||
// Badge + bare <img> cleanup only outside fenced/indented/inline code so
|
||||
// samples like `[](https://…)` inside fences are not rewritten.
|
||||
let body = mapOutsideCode(markdown, (plain) => {
|
||||
let next = normalizeLinkedBadgeImages(plain);
|
||||
next = next.replace(/<a\b[^>]*>[\s\S]*?<\/a>|<img\b[\s\S]*?>/gi, (chunk) => {
|
||||
if (/^<a\b/i.test(chunk)) return chunk;
|
||||
// Quote-aware img slice
|
||||
const end = findHtmlTagEnd(chunk.trim(), 0);
|
||||
const tag = end >= 0 ? chunk.trim().slice(0, end + 1) : chunk.trim();
|
||||
return convertHtmlImgTagToMarkdownOrHtml(tag) || "";
|
||||
});
|
||||
return normalizeLinkedBadgeImages(next);
|
||||
});
|
||||
body = stripOrphanLinkClosersOutsideCode(body);
|
||||
return trimBlankLines(body);
|
||||
};
|
||||
|
||||
export const convertClipboardHtmlToMarkdown = (html: string): string => {
|
||||
if (!looksLikeClipboardHtml(html)) return "";
|
||||
return normalizePastedNoteMarkdown(turndownFragment(html));
|
||||
};
|
||||
|
||||
/**
|
||||
* Extract a balanced HTML element starting at `start` (must point at '<').
|
||||
* Returns [fullMatch, endIndexExclusive] or null.
|
||||
*/
|
||||
export const extractBalancedHtmlElement = (
|
||||
source: string,
|
||||
start: number,
|
||||
): { full: string; end: number; tag: string } | null => {
|
||||
if (source[start] !== "<") return null;
|
||||
const openEnd = findHtmlTagEnd(source, start);
|
||||
if (openEnd < 0) return null;
|
||||
const openTag = source.slice(start, openEnd + 1);
|
||||
const tagMatch = /^<\/?([a-zA-Z][\w:-]*)/.exec(openTag);
|
||||
if (!tagMatch) return null;
|
||||
const tag = tagMatch[1].toLowerCase();
|
||||
if (/\/\s*>$/.test(openTag) || /^<(?:br|hr|img|meta|link|input|source|track|wbr)\b/i.test(openTag)) {
|
||||
return { full: openTag, end: openEnd + 1, tag };
|
||||
}
|
||||
if (openTag.startsWith("</")) return null;
|
||||
|
||||
// Raw-text elements: body is not HTML — do not treat `<` inside as nested tags.
|
||||
if (/^(?:script|style|textarea|title|xmp)$/i.test(tag)) {
|
||||
const closeRe = new RegExp(`</${tag}\\s*>`, "i");
|
||||
const rest = source.slice(openEnd + 1);
|
||||
const closeMatch = closeRe.exec(rest);
|
||||
if (!closeMatch) return null;
|
||||
const end = openEnd + 1 + closeMatch.index + closeMatch[0].length;
|
||||
return { full: source.slice(start, end), end, tag };
|
||||
}
|
||||
|
||||
let i = openEnd + 1;
|
||||
let depth = 1;
|
||||
while (i < source.length && depth > 0) {
|
||||
const next = source.indexOf("<", i);
|
||||
if (next < 0) return null;
|
||||
const te = findHtmlTagEnd(source, next);
|
||||
if (te < 0) return null;
|
||||
const piece = source.slice(next, te + 1);
|
||||
const tm = /^<\/?([a-zA-Z][\w:-]*)/.exec(piece);
|
||||
if (tm && tm[1].toLowerCase() === tag) {
|
||||
if (piece.startsWith("</")) depth -= 1;
|
||||
else if (!/\/\s*>$/.test(piece)) depth += 1;
|
||||
}
|
||||
i = te + 1;
|
||||
if (depth === 0) {
|
||||
return { full: source.slice(start, i), end: i, tag };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
export const convertHtmlIslandsInMarkdown = (markdown: string): string => {
|
||||
let body = markdown.replace(/\r\n?/g, "\n");
|
||||
|
||||
if (!plainMarkdownContainsHtml(body)) {
|
||||
return normalizePastedNoteMarkdown(body);
|
||||
}
|
||||
|
||||
// Mask fenced + indented + inline code so HTML samples in code are not Turndown'd.
|
||||
const { text: masked, slots, sentinel } = maskCodeRegions(body);
|
||||
body = masked.replace(/<!--[\s\S]*?-->/g, "");
|
||||
|
||||
// Walk left-to-right converting HTML islands with balanced matching.
|
||||
let out = "";
|
||||
let i = 0;
|
||||
while (i < body.length) {
|
||||
if (body[i] !== "<") {
|
||||
out += body[i];
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
const extracted = extractBalancedHtmlElement(body, i);
|
||||
if (!extracted) {
|
||||
out += body[i];
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
const { full, end, tag } = extracted;
|
||||
const lower = tag.toLowerCase();
|
||||
|
||||
if (lower === "script" || lower === "style") {
|
||||
i = end;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (lower === "div" && htmlOpenTagIsCentered(full)) {
|
||||
out += full;
|
||||
i = end;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (lower === "img" || lower === "br" || lower === "hr") {
|
||||
if (lower === "img") {
|
||||
const md = convertHtmlImgTagToMarkdownOrHtml(full);
|
||||
out += md ? `\n\n${md}\n\n` : "";
|
||||
} else {
|
||||
const md = turndownFragment(full.endsWith("/>") ? full : full.replace(/>$/, " />"));
|
||||
out += md || (lower === "br" ? " \n" : "");
|
||||
}
|
||||
i = end;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
(lower === "p" || /^h[1-6]$/.test(lower))
|
||||
&& htmlOpenTagIsCentered(full)
|
||||
) {
|
||||
const md = turndownFragment(full);
|
||||
out += md.trim() ? `\n\n${md.trim()}\n\n` : "";
|
||||
i = end;
|
||||
continue;
|
||||
}
|
||||
|
||||
const md = turndownFragment(full);
|
||||
if (md.trim()) {
|
||||
if (
|
||||
/^(p|div|section|article|table|ul|ol|blockquote|h[1-6]|pre|figure)$/i.test(lower)
|
||||
) {
|
||||
out += `\n\n${md.trim()}\n\n`;
|
||||
} else {
|
||||
out += md;
|
||||
}
|
||||
}
|
||||
i = end;
|
||||
}
|
||||
|
||||
return normalizePastedNoteMarkdown(unmaskCodeRegions(out, slots, sentinel));
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolve clipboard plain + html into note markdown.
|
||||
* Structured text/plain wins over presentation HTML wrappers.
|
||||
*/
|
||||
export const resolveNoteClipboardPaste = (input: {
|
||||
plainText: string;
|
||||
htmlText: string;
|
||||
}): NoteClipboardPastePayload => {
|
||||
const plain = (input.plainText ?? "").replace(/\r\n?/g, "\n");
|
||||
const html = input.htmlText ?? "";
|
||||
|
||||
// 1) Structured plain Markdown is authoritative (browser often also puts
|
||||
// wrapper HTML that would escape # / ** if Turndown runs first).
|
||||
if (shouldInsertClipboardTextAsMarkdown(plain)) {
|
||||
if (plainMarkdownContainsHtml(plain)) {
|
||||
const converted = convertHtmlIslandsInMarkdown(plain);
|
||||
if (converted.trim()) {
|
||||
return {
|
||||
text: converted,
|
||||
kind: plainMarkdownContainsHtml(converted) ? "markdown" : "html-converted",
|
||||
};
|
||||
}
|
||||
}
|
||||
return { text: normalizePastedNoteMarkdown(plain), kind: "markdown" };
|
||||
}
|
||||
|
||||
// 2) Rich HTML document (browser / Word / GitHub render clipboard)
|
||||
if (looksLikeClipboardHtml(html) && isPrimarilyHtmlDocument(html)) {
|
||||
const converted = convertClipboardHtmlToMarkdown(html);
|
||||
if (converted.trim()) {
|
||||
return { text: converted, kind: "html-converted" };
|
||||
}
|
||||
}
|
||||
|
||||
// 3) Non-primary HTML fragment when plain is unstructured
|
||||
if (looksLikeClipboardHtml(html)) {
|
||||
const converted = convertClipboardHtmlToMarkdown(html);
|
||||
if (converted.trim()) {
|
||||
return { text: converted, kind: "html-converted" };
|
||||
}
|
||||
}
|
||||
|
||||
// 4) Plain that is mostly HTML fragment
|
||||
if (looksLikeClipboardHtml(plain)) {
|
||||
const converted = convertClipboardHtmlToMarkdown(plain);
|
||||
if (converted.trim()) {
|
||||
return { text: converted, kind: "html-converted" };
|
||||
}
|
||||
}
|
||||
|
||||
if (plain.trim()) {
|
||||
return { text: plain, kind: "plain" };
|
||||
}
|
||||
|
||||
return { text: "", kind: "empty" };
|
||||
};
|
||||
|
||||
export const shouldInterceptResolvedNotePaste = (input: {
|
||||
editorMode: "edit" | "preview" | "source" | "live" | string;
|
||||
pasteInsideCodeBlock: boolean;
|
||||
payload: NoteClipboardPastePayload;
|
||||
}): boolean => {
|
||||
if (input.editorMode !== "edit") return false;
|
||||
if (input.pasteInsideCodeBlock) return false;
|
||||
if (input.payload.kind === "empty") return false;
|
||||
if (input.payload.kind === "html-converted") return true;
|
||||
if (input.payload.kind === "markdown") return true;
|
||||
return false;
|
||||
};
|
||||
91
domain/notes/taskList.test.ts
Normal file
91
domain/notes/taskList.test.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
countTaskListItems,
|
||||
isPointerOnTaskCheckbox,
|
||||
toggleTaskListItemAtIndex,
|
||||
} from "./taskList";
|
||||
|
||||
test("toggleTaskListItemAtIndex flips the Nth GFM checkbox", () => {
|
||||
const src = [
|
||||
"# List",
|
||||
"",
|
||||
"- [ ] one",
|
||||
"- [x] two",
|
||||
"* [ ] three",
|
||||
"1. [ ] four",
|
||||
].join("\n");
|
||||
|
||||
assert.equal(countTaskListItems(src), 4);
|
||||
assert.match(toggleTaskListItemAtIndex(src, 0), /^- \[x\] one$/m);
|
||||
assert.match(toggleTaskListItemAtIndex(src, 1), /^- \[ \] two$/m);
|
||||
assert.match(toggleTaskListItemAtIndex(src, 2), /^\* \[x\] three$/m);
|
||||
assert.match(toggleTaskListItemAtIndex(src, 3), /^1\. \[x\] four$/m);
|
||||
assert.equal(toggleTaskListItemAtIndex(src, 9), src);
|
||||
assert.equal(toggleTaskListItemAtIndex(src, -1), src);
|
||||
});
|
||||
|
||||
test("toggleTaskListItemAtIndex preserves indentation and surrounding text", () => {
|
||||
const src = " - [ ] nested code `apt`\n- [x] done";
|
||||
const next = toggleTaskListItemAtIndex(src, 0);
|
||||
assert.equal(next, " - [x] nested code `apt`\n- [x] done");
|
||||
});
|
||||
|
||||
test("toggleTaskListItemAtIndex ignores checkboxes inside fenced code", () => {
|
||||
const src = [
|
||||
"- [ ] real",
|
||||
"```",
|
||||
"- [ ] fake",
|
||||
"```",
|
||||
"- [ ] second",
|
||||
].join("\n");
|
||||
assert.equal(countTaskListItems(src), 2);
|
||||
const next = toggleTaskListItemAtIndex(src, 1);
|
||||
assert.match(next, /^- \[ \] real$/m);
|
||||
assert.match(next, /^- \[ \] fake$/m);
|
||||
assert.match(next, /^- \[x\] second$/m);
|
||||
});
|
||||
|
||||
test("toggleTaskListItemAtIndex handles blockquote task lines", () => {
|
||||
const src = "> - [ ] quoted\n- [ ] plain";
|
||||
assert.equal(countTaskListItems(src), 2);
|
||||
assert.match(toggleTaskListItemAtIndex(src, 0), /^> - \[x\] quoted$/m);
|
||||
});
|
||||
|
||||
test("toggleTaskListItemAtIndex recognizes parenthesized ordered markers", () => {
|
||||
const src = "1) [ ] first\n2. [ ] second";
|
||||
assert.equal(countTaskListItems(src), 2);
|
||||
assert.match(toggleTaskListItemAtIndex(src, 0), /^1\) \[x\] first$/m);
|
||||
});
|
||||
|
||||
test("toggleTaskListItemAtIndex counts nested list tasks", () => {
|
||||
const src = "- parent\n - [ ] child\n- [ ] later";
|
||||
assert.equal(countTaskListItems(src), 2);
|
||||
assert.match(toggleTaskListItemAtIndex(src, 0), / {4}- \[x\] child/);
|
||||
assert.match(toggleTaskListItemAtIndex(src, 1), /^- \[x\] later$/m);
|
||||
});
|
||||
|
||||
test("toggleTaskListItemAtIndex ignores tasks without space after bracket", () => {
|
||||
const src = "- [ ]foo\n- [ ] real";
|
||||
assert.equal(countTaskListItems(src), 1);
|
||||
assert.match(toggleTaskListItemAtIndex(src, 0), /^- \[x\] real$/m);
|
||||
assert.match(toggleTaskListItemAtIndex(src, 0), /^- \[ \]foo$/m);
|
||||
});
|
||||
|
||||
test("toggleTaskListItemAtIndex ignores tasks inside HTML comments", () => {
|
||||
const src = "<!--\n- [ ] hidden\n-->\n- [ ] visible";
|
||||
assert.equal(countTaskListItems(src), 1);
|
||||
const next = toggleTaskListItemAtIndex(src, 0);
|
||||
assert.match(next, /<!--\n- \[ \] hidden\n-->/);
|
||||
assert.match(next, /^- \[x\] visible$/m);
|
||||
});
|
||||
|
||||
test("isPointerOnTaskCheckbox only accepts the left hit box", () => {
|
||||
const rect = { left: 100, right: 400 };
|
||||
assert.equal(isPointerOnTaskCheckbox(rect, 100), true);
|
||||
assert.equal(isPointerOnTaskCheckbox(rect, 120), true);
|
||||
assert.equal(isPointerOnTaskCheckbox(rect, 128), true);
|
||||
assert.equal(isPointerOnTaskCheckbox(rect, 129), false);
|
||||
assert.equal(isPointerOnTaskCheckbox(rect, 99), false);
|
||||
});
|
||||
107
domain/notes/taskList.ts
Normal file
107
domain/notes/taskList.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* GFM task-list helpers for note markdown.
|
||||
* Lexical CheckListPlugin only toggles when the editor is editable; preview mode
|
||||
* reuses these pure transforms so checkboxes stay clickable.
|
||||
*/
|
||||
|
||||
import { maskCodeRegions, unmaskCodeRegions } from "./clipboardPaste";
|
||||
|
||||
/**
|
||||
* Matches "- [ ]", "* [x]", "1. [X]", "1) [ ]", and optional blockquote
|
||||
* prefixes (`> - [ ]`) at line start. Code regions and HTML comments are
|
||||
* masked before matching so they never steal a DOM checkbox index.
|
||||
*/
|
||||
// Require whitespace (or EOL) after `]` so `- [ ]foo` is not treated as a task.
|
||||
const TASK_LIST_ITEM_PATTERN =
|
||||
"^([ \\t]*(?:>[ \\t]*)*(?:[-*+]|\\d+[.)])[ \\t]+)\\[([ xX])\\](?=\\s|$)";
|
||||
|
||||
const createTaskListItemRe = (): RegExp => new RegExp(TASK_LIST_ITEM_PATTERN, "gm");
|
||||
|
||||
const escapeRegExp = (value: string): string => (
|
||||
value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
|
||||
);
|
||||
|
||||
/** Mask HTML comments with a unique sentinel so task indices skip them. */
|
||||
const maskHtmlComments = (markdown: string): {
|
||||
text: string;
|
||||
slots: string[];
|
||||
sentinel: string;
|
||||
} => {
|
||||
let n = 0;
|
||||
let sentinel = "@@NETCATTY_MD_COMMENT_";
|
||||
while (markdown.includes(sentinel)) {
|
||||
n += 1;
|
||||
sentinel = `@@NETCATTY_MD_COMMENT_S${n}_`;
|
||||
}
|
||||
const slots: string[] = [];
|
||||
const text = markdown.replace(/<!--[\s\S]*?-->/g, (chunk) => {
|
||||
const token = `${sentinel}${slots.length}@@`;
|
||||
slots.push(chunk);
|
||||
return token;
|
||||
});
|
||||
return { text, slots, sentinel };
|
||||
};
|
||||
|
||||
const unmaskHtmlComments = (
|
||||
text: string,
|
||||
slots: string[],
|
||||
sentinel: string,
|
||||
): string => {
|
||||
const re = new RegExp(`${escapeRegExp(sentinel)}(\\d+)@@`, "g");
|
||||
return text.replace(re, (_, idx: string) => slots[Number(idx)] ?? "");
|
||||
};
|
||||
|
||||
/** Prepare markdown for task scanning: code regions then HTML comments. */
|
||||
const prepareTaskScanText = (markdown: string): {
|
||||
text: string;
|
||||
restore: (body: string) => string;
|
||||
} => {
|
||||
const codeMask = maskCodeRegions(markdown);
|
||||
const commentMask = maskHtmlComments(codeMask.text);
|
||||
return {
|
||||
text: commentMask.text,
|
||||
restore: (body: string) => {
|
||||
const withComments = unmaskHtmlComments(
|
||||
body,
|
||||
commentMask.slots,
|
||||
commentMask.sentinel,
|
||||
);
|
||||
return unmaskCodeRegions(withComments, codeMask.slots, codeMask.sentinel);
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export const countTaskListItems = (markdown: string): number => {
|
||||
const { text } = prepareTaskScanText(markdown);
|
||||
return text.match(createTaskListItemRe())?.length ?? 0;
|
||||
};
|
||||
|
||||
/**
|
||||
* Toggle the Nth GFM task checkbox (0-based order among rendered tasks:
|
||||
* outside fenced/indented/inline code and HTML comments). Returns the original
|
||||
* string when the index is out of range.
|
||||
*/
|
||||
export const toggleTaskListItemAtIndex = (markdown: string, index: number): string => {
|
||||
if (index < 0 || !Number.isFinite(index)) return markdown;
|
||||
|
||||
const { text, restore } = prepareTaskScanText(markdown);
|
||||
let seen = 0;
|
||||
let changed = false;
|
||||
const next = text.replace(createTaskListItemRe(), (full, prefix: string, mark: string) => {
|
||||
if (seen++ !== index) return full;
|
||||
changed = true;
|
||||
const nextMark = mark === " " ? "x" : " ";
|
||||
return `${prefix}[${nextMark}]`;
|
||||
});
|
||||
|
||||
return changed ? restore(next) : markdown;
|
||||
};
|
||||
|
||||
/** Left-edge hit box for checklist toggles (checkbox + padding), in CSS px. */
|
||||
export const NOTE_TASK_CHECKBOX_HIT_PX = 28;
|
||||
|
||||
export const isPointerOnTaskCheckbox = (
|
||||
listItemRect: Pick<DOMRect, "left" | "right">,
|
||||
clientX: number,
|
||||
hitPx: number = NOTE_TASK_CHECKBOX_HIT_PX,
|
||||
): boolean => clientX >= listItemRect.left && clientX <= listItemRect.left + hitPx;
|
||||
Reference in New Issue
Block a user