Merge pull request #9643 from streetwriters/core/validate-inbox-items

core: validate & sanitize inbox items
This commit is contained in:
Abdullah Atta
2026-04-07 20:41:25 +05:00
committed by GitHub
7 changed files with 657 additions and 36 deletions

View File

@@ -23,6 +23,7 @@
"dayjs": "1.11.13",
"dom-serializer": "^2.0.0",
"domhandler": "^5.0.3",
"dompurify": "^3.3.3",
"domutils": "^3.1.0",
"entities": "5.0.0",
"fuzzyjs": "^5.0.1",
@@ -35,7 +36,8 @@
"prismjs": "^1.29.0",
"qclone": "^1.2.0",
"rfdc": "^1.3.0",
"spark-md5": "^3.0.2"
"spark-md5": "^3.0.2",
"zod": "^4.3.6"
},
"devDependencies": {
"@notesnook/crypto": "file:../crypto",
@@ -1184,6 +1186,13 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/trusted-types": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
"license": "MIT",
"optional": true
},
"node_modules/@types/unist": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz",
@@ -1881,6 +1890,15 @@
"url": "https://github.com/fb55/domhandler?sponsor=1"
}
},
"node_modules/dompurify": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.3.tgz",
"integrity": "sha512-Oj6pzI2+RqBfFG+qOaOLbFXLQ90ARpcGG6UePL82bJLtdsa6CYJD7nmiU8MW9nQNOtCHV3lZ/Bzq1X0QYbBZCA==",
"license": "(MPL-2.0 OR Apache-2.0)",
"optionalDependencies": {
"@types/trusted-types": "^2.0.7"
}
},
"node_modules/domutils": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",
@@ -4231,6 +4249,15 @@
"optional": true
}
}
},
"node_modules/zod": {
"version": "4.3.6",
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
}
}
}

View File

@@ -78,6 +78,7 @@
"dayjs": "1.11.13",
"dom-serializer": "^2.0.0",
"domhandler": "^5.0.3",
"dompurify": "^3.3.3",
"domutils": "^3.1.0",
"entities": "5.0.0",
"fuzzyjs": "^5.0.1",
@@ -90,7 +91,8 @@
"prismjs": "^1.29.0",
"qclone": "^1.2.0",
"rfdc": "^1.3.0",
"spark-md5": "^3.0.2"
"spark-md5": "^3.0.2",
"zod": "^4.3.6"
},
"overrides": {
"htmlparser2": "^10.0.0"

View File

@@ -28,7 +28,9 @@ import {
Note,
isDeleted
} from "../../types.js";
import { ParsedInboxItem, SyncInboxItem } from "./types.js";
import { SyncInboxItem } from "./types.js";
import { z } from "zod";
import { sanitizeHtml } from "../../utils/html-parser.js";
const THRESHOLD = process.env.NODE_ENV === "test" ? 2 * 1000 : 60 * 1000;
class Merger {
@@ -168,6 +170,25 @@ export function isContentConflicted(
}
}
const RawInboxItemSchema = z.object({
title: z.string().min(1, "Title is required"),
pinned: z.boolean().optional(),
favorite: z.boolean().optional(),
readonly: z.boolean().optional(),
archived: z.boolean().optional(),
notebookIds: z.array(z.string()).optional(),
tagIds: z.array(z.string()).optional(),
type: z.enum(["note"]),
source: z.string(),
version: z.literal(1),
content: z
.object({
type: z.enum(["html"]),
data: z.string()
})
.optional()
});
export async function handleInboxItems(
inboxItems: SyncInboxItem[],
db: Database
@@ -190,34 +211,37 @@ export async function handleInboxItems(
const decryptedItem = await db
.storage()
.decryptPGPMessage(inboxKeys.privateKey, item.cipher);
const parsed = JSON.parse(decryptedItem) as ParsedInboxItem;
if (parsed.type !== "note") {
continue;
}
if (parsed.version !== 1) {
const validation = RawInboxItemSchema.safeParse(
JSON.parse(decryptedItem)
);
if (!validation.success) {
logger.warn("Failed to validate inbox item.", {
inboxItem: item,
errors: validation.error.issues
});
continue;
}
const data = validation.data;
await db.notes.add({
id: item.id,
title: parsed.title,
favorite: parsed.favorite,
pinned: parsed.pinned,
readonly: parsed.readonly,
title: data.title,
favorite: data.favorite,
pinned: data.pinned,
readonly: data.readonly,
content: {
data: parsed?.content?.data ?? "",
data: sanitizeHtml(data?.content?.data ?? ""),
type: "tiptap"
}
});
if (parsed.archived !== undefined) {
await db.notes.archive(parsed.archived, item.id);
if (data.archived !== undefined) {
await db.notes.archive(data.archived, item.id);
}
for (const notebookId of parsed.notebookIds || []) {
for (const notebookId of data.notebookIds || []) {
if (!(await db.notebooks.exists(notebookId))) continue;
await db.notes.addToNotebook(notebookId, item.id);
}
for (const tagId of parsed.tagIds || []) {
for (const tagId of data.tagIds || []) {
if (!(await db.tags.exists(tagId))) continue;
await db.relations.add(
{ type: "tag", id: tagId },

View File

@@ -64,20 +64,3 @@ export type SyncInboxItem = {
cipher: string;
alg: string;
};
export type ParsedInboxItem = {
title: string;
pinned?: boolean;
favorite?: boolean;
readonly?: boolean;
archived?: boolean;
notebookIds?: string[];
tagIds?: string[];
type: "note";
source: string;
version: 1;
content?: {
type: "html";
data: string;
};
};

View File

@@ -0,0 +1,299 @@
/*
This file is part of the Notesnook project (https://notesnook.com/)
Copyright (C) 2023 Streetwriters (Private) Limited
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { normalizeToHtmlBody, sanitizeHtml } from "../html-parser.js";
import { expect, describe, it } from "vitest";
const HTML_INPUT_TYPES: Array<{
title: string;
input: string;
expected: string;
}> = [
{
title: "empty input",
input: "",
expected: "<html><body></body></html>"
},
{
title: "whitespace input",
input: " \n\t ",
expected: "<html><body></body></html>"
},
{
title: "plain text",
input: "Hello world",
expected: "<html><body>Hello world</body></html>"
},
{
title: "html fragment",
input: "<p>Hello</p>",
expected: "<html><body><p>Hello</p></body></html>"
},
{
title: "complete html with body",
input: "<html><body><p>Hello</p></body></html>",
expected: "<html><body><p>Hello</p></body></html>"
},
{
title: "complete html with body attributes",
input: '<html><body class="editor" data-id="1">Hello</body></html>',
expected: '<html><body class="editor" data-id="1">Hello</body></html>'
},
{
title: "html without body",
input: "<html><head><title>T</title></head><p>Hello</p></html>",
expected:
"<html><head><title>T</title></head><body><p>Hello</p></body></html>"
},
{
title: "doctype html without body",
input: "<!doctype html><html><head></head><div>Hello</div></html>",
expected: "<html><head></head><body><div>Hello</div></body></html>"
},
{
title: "body without html",
input: "<body><p>Hello</p></body>",
expected: "<html><body><p>Hello</p></body></html>"
},
{
title: "body with attributes without html",
input: '<body class="editor"><p>Hello</p></body>',
expected: '<html><body class="editor"><p>Hello</p></body></html>'
},
{
title: "body without closing tag",
input: "<body><p>Hello</p>",
expected: "<html><body><p>Hello</p></body></html>"
},
{
title: "html with unclosed body",
input: '<html><body class="editor"><p>Hello</p></html>',
expected: '<html><body class="editor"><p>Hello</p></body></html>'
},
{
title: "uppercase tags",
input: "<HTML><BODY><p>Hello</p></BODY></HTML>",
expected: "<html><BODY><p>Hello</p></BODY></html>"
},
{
title: "orphaned closing tag",
input: "Hello</p>World",
expected:
"<html><body><pre><code>Hello&lt;/p&gt;World</code></pre></body></html>"
},
{
title: "unclosed tag at end",
input: "<div><p>Hello",
expected:
"<html><body><pre><code>&lt;div&gt;&lt;p&gt;Hello</code></pre></body></html>"
},
{
title: "mismatched closing tags",
input: "<div><p>Hello</div></p>",
expected: "<html><body><div><p>Hello</div></p></body></html>"
},
{
title: "unclosed body tag in fragment",
input: "<body><p>Hello</p>",
expected: "<html><body><p>Hello</p></body></html>"
}
];
describe("normalizeToHtmlBody", () => {
HTML_INPUT_TYPES.forEach(({ title, input, expected }) => {
it(`should normalize ${title}`, () => {
expect(normalizeToHtmlBody(input)).toBe(expected);
});
});
it("should always return html and body sequence at root", () => {
for (const { input } of HTML_INPUT_TYPES) {
const normalized = normalizeToHtmlBody(input).toLowerCase();
expect(normalized.startsWith("<html>")).toBe(true);
expect(normalized.includes("<body")).toBe(true);
expect(normalized.endsWith("</body></html>")).toBe(true);
}
});
it("should handle runtime non-string values safely", () => {
expect(normalizeToHtmlBody(null as unknown as string)).toBe(
"<html><body></body></html>"
);
expect(normalizeToHtmlBody(undefined as unknown as string)).toBe(
"<html><body></body></html>"
);
});
});
// sanitizeHtml uses globalThis.DOMParser (set to linkedom's DOMParser in
// test.setup.ts) to back DOMPurify when a native browser DOM is unavailable.
describe("sanitizeHtml", () => {
it("strips <script> tags and their content", () => {
const result = sanitizeHtml("<p>Hello</p><script>alert(1)</script>");
expect(result).not.toContain("<script");
expect(result).not.toContain("alert(1)");
expect(result).toContain("Hello");
});
it("strips inline event handlers", () => {
const result = sanitizeHtml('<img src="x" onerror="alert(1)">');
expect(result).not.toContain("onerror");
expect(result).not.toContain("alert(1)");
});
it("strips javascript: URIs from href", () => {
// eslint-disable-next-line no-script-url
const result = sanitizeHtml('<a href="javascript:alert(1)">click</a>');
expect(result).not.toContain("javascript:");
expect(result).toContain("click");
});
it("strips javascript: URIs from src", () => {
// eslint-disable-next-line no-script-url
const result = sanitizeHtml(
'<iframe src="javascript:alert(document.domain)"></iframe>'
);
expect(result).not.toContain("javascript:");
});
it("strips onclick and other on* attributes", () => {
const result = sanitizeHtml(
'<button onclick="evil()">OK</button><div onmouseover="evil()">x</div>'
);
expect(result).not.toContain("onclick");
expect(result).not.toContain("onmouseover");
expect(result).not.toContain("evil()");
});
it("strips <object> and <embed> tags", () => {
const result = sanitizeHtml(
'<object data="malicious.swf"></object><embed src="evil.swf">'
);
expect(result).not.toContain("<object");
expect(result).not.toContain("<embed");
});
it("strips data: URIs in dangerous attributes", () => {
const result = sanitizeHtml(
'<a href="data:text/html,<script>alert(1)</script>">x</a>'
);
expect(result).not.toMatch(/href=["']data:/i);
});
it("preserves safe block elements", () => {
const input = "<p>Hello <strong>world</strong></p><ul><li>item</li></ul>";
const result = sanitizeHtml(input);
expect(result).toContain("<p>");
expect(result).toContain("<strong>world</strong>");
expect(result).toContain("<ul>");
expect(result).toContain("<li>item</li>");
});
it("preserves safe links with http/https href", () => {
const result = sanitizeHtml('<a href="https://notesnook.com">Notes</a>');
expect(result).toContain('href="https://notesnook.com"');
expect(result).toContain("Notes");
});
it("preserves headings", () => {
const result = sanitizeHtml("<h1>Title</h1><h2>Subtitle</h2>");
expect(result).toContain("<h1>Title</h1>");
expect(result).toContain("<h2>Subtitle</h2>");
});
it("returns a string (not TrustedHTML or DOM node)", () => {
const result = sanitizeHtml("<p>test</p>");
expect(typeof result).toBe("string");
});
it("handles empty input without throwing", () => {
expect(() => sanitizeHtml("")).not.toThrow();
const result = sanitizeHtml("");
expect(typeof result).toBe("string");
});
it("handles plain text without throwing", () => {
const result = sanitizeHtml("just plain text");
expect(result).toContain("just plain text");
expect(typeof result).toBe("string");
});
it("handles deeply nested XSS attempts", () => {
const result = sanitizeHtml(
"<div><p><span onmouseover=\"alert('xss')\">hover</span></p></div>"
);
expect(result).not.toContain("onmouseover");
expect(result).toContain("hover");
});
it("strips <base> tag that could hijack relative URLs", () => {
const result = sanitizeHtml(
'<base href="https://evil.com"><a href="/path">link</a>'
);
expect(result).not.toContain("<base");
});
it("preserves <iframe> with safe https src", () => {
const result = sanitizeHtml('<iframe src="https://example.com"></iframe>');
expect(result).toContain("<iframe");
expect(result).toContain('src="https://example.com"');
});
it("strips src from <iframe> with javascript: URI", () => {
// eslint-disable-next-line no-script-url
const result = sanitizeHtml(
'<iframe src="javascript:alert(document.domain)"></iframe>'
);
expect(result).toContain("<iframe");
expect(result).not.toContain("javascript:");
});
it("strips src from <iframe> with data: URI", () => {
const result = sanitizeHtml(
'<iframe src="data:text/html,<script>alert(1)</script>"></iframe>'
);
expect(result).toContain("<iframe");
expect(result).not.toContain("data:");
});
it("strips srcdoc from <iframe>", () => {
const result = sanitizeHtml(
'<iframe srcdoc="<script>alert(1)</script>"></iframe>'
);
expect(result).toContain("<iframe");
expect(result).not.toContain("srcdoc");
});
it("strips event handlers from <iframe>", () => {
const result = sanitizeHtml(
'<iframe src="https://example.com" onload="steal()"></iframe>'
);
expect(result).toContain("<iframe");
expect(result).not.toContain("onload");
expect(result).not.toContain("steal()");
});
it("preserves nested <iframe> with safe src alongside other elements", () => {
const result = sanitizeHtml(
'<div><p>Safe content</p><iframe src="https://example.com"></iframe></div>'
);
expect(result).toContain("Safe content");
expect(result).toContain("<iframe");
expect(result).toContain('src="https://example.com"');
});
});

View File

@@ -0,0 +1,117 @@
/*
This file is part of the Notesnook project (https://notesnook.com/)
Copyright (C) 2023 Streetwriters (Private) Limited
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import DOMPurify, { WindowLike } from "dompurify";
const parseHTML = (input: string) =>
"DOMParser" in globalThis
? new globalThis.DOMParser().parseFromString(input, "text/html")
: null;
function ensureNodeFilter(win: any) {
if (win.NodeFilter) return;
function NodeFilter() {}
NodeFilter.FILTER_ACCEPT = 1;
NodeFilter.FILTER_REJECT = 2;
NodeFilter.FILTER_SKIP = 3;
NodeFilter.SHOW_ALL = 0xff_ff_ff_ff;
NodeFilter.SHOW_ELEMENT = 0x1;
NodeFilter.SHOW_ATTRIBUTE = 0x2;
NodeFilter.SHOW_TEXT = 0x4;
NodeFilter.SHOW_CDATA_SECTION = 0x8;
NodeFilter.SHOW_ENTITY_REFERENCE = 0x10;
NodeFilter.SHOW_ENTITY = 0x20;
NodeFilter.SHOW_PROCESSING_INSTRUCTION = 0x40;
NodeFilter.SHOW_COMMENT = 0x80;
NodeFilter.SHOW_DOCUMENT = 0x1_00;
NodeFilter.SHOW_DOCUMENT_TYPE = 0x2_00;
NodeFilter.SHOW_DOCUMENT_FRAGMENT = 0x4_00;
NodeFilter.SHOW_NOTATION = 0x8_00;
win.NodeFilter = NodeFilter;
}
function createPurifyWindow() {
const document = parseHTML(
"<!doctype html><html><head><title>dompurify</title></head><body></body></html>"
);
const win = document?.defaultView as any;
if (!win)
throw new Error("DOMParser is unavailable; cannot initialize DOMPurify");
const doc = win.document;
// DOMPurify calls new DOMParser().parseFromString(...)
if (!win.DOMParser) {
win.DOMParser = DOMParser;
}
ensureNodeFilter(win);
// DOMPurify checks existence AND later relies on callable behavior.
if (!doc.implementation) {
doc.implementation = {};
}
if (typeof doc.implementation.createHTMLDocument !== "function") {
doc.implementation.createHTMLDocument = (title = "") => {
const document = parseHTML(
"<!doctype html><html><head><title>dompurify</title></head><body></body></html>"
);
return document;
};
}
if (typeof doc.implementation.createDocument !== "function") {
doc.implementation.createDocument = () => {
// Keep this as HTML doc because DOMPurify asks for body/html via getElementsByTagName.
return parseHTML(
"<!doctype html><html><head></head><body></body></html>"
);
};
}
return win as WindowLike;
}
let domPurify: DOMPurify.DOMPurify | undefined = undefined;
function getDomPurify() {
if (!domPurify) {
const win = DOMPurify.isSupported
? (globalThis as unknown as WindowLike)
: createPurifyWindow();
domPurify = DOMPurify(win);
// Only allow https/http src on iframes — strip anything else (javascript:,
// data:, relative paths that could be weaponized, etc.).
domPurify.addHook("uponSanitizeAttribute", (node, data) => {
if (
node.tagName === "IFRAME" &&
data.attrName === "src" &&
!/^https?:\/\//i.test(data.attrValue)
) {
data.keepAttr = false;
}
});
}
return domPurify;
}
export { getDomPurify };

View File

@@ -17,8 +17,9 @@ You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { decodeHTML5 } from "entities";
import { decodeHTML5, escape } from "entities";
import { Parser } from "htmlparser2";
import { getDomPurify } from "./dom-purify";
export const parseHTML = (input: string) =>
"DOMParser" in globalThis
@@ -28,6 +29,14 @@ export const parseHTML = (input: string) =>
)
: null;
export const sanitizeHtml = (html: string): string => {
const inputHtml = normalizeToHtmlBody(html);
return getDomPurify().sanitize(inputHtml, {
RETURN_DOM: false,
ADD_TAGS: ["iframe"]
}) as string;
};
export function getDummyDocument() {
const doc = parseHTML("<div></div>");
return doc;
@@ -44,6 +53,166 @@ function wrapIntoHTMLDocument(input: string) {
return `<!doctype html><html lang="en"><head><title>Document Fragment</title></head><body>${input}</body></html>`;
}
const SELF_CLOSING_TAGS = new Set([
"area",
"base",
"br",
"col",
"embed",
"hr",
"img",
"input",
"link",
"meta",
"param",
"source",
"track",
"wbr"
]);
function isHtmlValid(html: string): boolean {
const trimmed = html.trim();
if (!trimmed) return true;
// Strip comments and script/style content before tag counting to avoid
// false matches on tags appearing inside comments or raw text blocks.
const stripped = trimmed
.replace(/<!--[\s\S]*?-->/g, "")
.replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, "")
.replace(/<style\b[^>]*>[\s\S]*?<\/style>/gi, "");
// Extract all opening and closing tag names
const openTagMatches = stripped.matchAll(/<([a-z][a-z0-9]*)\b/gi);
const closeTagMatches = stripped.matchAll(/<\/([a-z][a-z0-9]*)\b/gi);
const openTags = Array.from(openTagMatches, (m) => m[1].toLowerCase());
const closeTags = Array.from(closeTagMatches, (m) => m[1].toLowerCase());
// Document-level tags (body, html, head) are allowed to be unclosed in fragments
const documentTags = new Set(["body", "html", "head"]);
// Count content tags (non-document, non-void tags) — void/self-closing elements
// never have a closing tag so they must not affect the balance check.
const openContentTags = openTags.filter(
(tag) => !documentTags.has(tag) && !SELF_CLOSING_TAGS.has(tag)
);
const closeContentTags = closeTags.filter(
(tag) => !documentTags.has(tag) && !SELF_CLOSING_TAGS.has(tag)
);
// For content tags: opening and closing must match
if (openContentTags.length !== closeContentTags.length) {
return false;
}
// Now do strict tag matching for actual mismatches
const openStack: string[] = [];
let hasError = false;
const parser = new Parser(
{
onopentag: (name) => {
if (!SELF_CLOSING_TAGS.has(name.toLowerCase())) {
openStack.push(name.toLowerCase());
}
},
onclosetag: (name) => {
const nameLower = name.toLowerCase();
// htmlparser2 fires onclosetag for void/self-closing elements immediately
// after onopentag. We never push them onto the stack, so skip here too.
if (SELF_CLOSING_TAGS.has(nameLower)) return;
const lastOpen = openStack[openStack.length - 1];
if (!lastOpen) {
hasError = true;
return;
}
if (lastOpen === nameLower) {
openStack.pop();
} else {
// Any tag mismatch is an error (except for auto-fixed document tags)
hasError = true;
}
}
},
{
lowerCaseTags: true
}
);
try {
parser.end(html);
// Unclosed content tags = invalid
if (openStack.length > 0) {
return false;
}
return !hasError;
} catch {
return false;
}
}
function wrapInCodeBlock(html: string): string {
const escaped = escape(html);
return `<html><body><pre><code>${escaped}</code></pre></body></html>`;
}
export function normalizeToHtmlBody(input: string) {
const source = typeof input === "string" ? input.trim() : "";
if (!source) return "<html><body></body></html>";
// If HTML has broken/incomplete tags, wrap in code block for display
if (!isHtmlValid(source)) {
return wrapInCodeBlock(source);
}
const hasHtmlTag = /<html\b[^>]*>/i.test(source);
const hasBodyOpenTag = /<body\b[^>]*>/i.test(source);
const hasBodyCloseTag = /<\/body>/i.test(source);
// If a full body block exists, normalize to <html><body...>...</body></html>.
const bodyBlock = source.match(/<body\b[^>]*>[\s\S]*?<\/body>/i)?.[0];
if (bodyBlock) {
return `<html>${bodyBlock}</html>`;
}
// HTML exists but no complete body: strip outer html and wrap remaining content in body.
if (hasHtmlTag) {
const inner = source
.replace(/<!doctype[^>]*>/i, "")
.replace(/<html\b[^>]*>/i, "")
.replace(/<\/html>/i, "")
.trim();
const headBlock = inner.match(/<head\b[^>]*>[\s\S]*?<\/head>/i)?.[0];
// Handle case with <body ...> present but missing </body>.
if (hasBodyOpenTag && !hasBodyCloseTag) {
const bodyOpen = inner.match(/<body\b[^>]*>/i)?.[0] || "<body>";
const bodyContent = inner.replace(/<body\b[^>]*>/i, "");
return `<html>${bodyOpen}${bodyContent}</body></html>`;
}
if (headBlock) {
const bodyContent = inner.replace(headBlock, "").trim();
return `<html>${headBlock}<body>${bodyContent}</body></html>`;
}
return `<html><body>${inner}</body></html>`;
}
// Body exists without html: add html wrapper, and close body if needed.
if (hasBodyOpenTag) {
if (!hasBodyCloseTag) return `<html>${source}</body></html>`;
return `<html>${source}</html>`;
}
// Plain fragment/text.
return `<html><body>${source}</body></html>`;
}
export function extractHeadline(html: string) {
let text = "";
let start = false;