diff --git a/apps/client/tsconfig.app.json b/apps/client/tsconfig.app.json
index 16b93e1ce..4abc845e4 100644
--- a/apps/client/tsconfig.app.json
+++ b/apps/client/tsconfig.app.json
@@ -1,7 +1,7 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
- "lib": [ "ESNext" ],
+ "lib": [ "ESNext", "DOM.Iterable" ],
"outDir": "dist",
"types": [
"node",
diff --git a/apps/desktop/scripts/build.ts b/apps/desktop/scripts/build.ts
index 679dc71f0..945a4cfeb 100644
--- a/apps/desktop/scripts/build.ts
+++ b/apps/desktop/scripts/build.ts
@@ -15,7 +15,6 @@ async function main() {
// Copy node modules dependencies
build.copyNodeModules([ "better-sqlite3", "bindings", "file-uri-to-path", "@electron/remote" ]);
- build.copy("/node_modules/jsdom/lib/jsdom/living/xhr/xhr-sync-worker.js", "xhr-sync-worker.js");
build.copy("/node_modules/ckeditor5/dist/ckeditor5-content.css", "ckeditor5-content.css");
// Integrate the client.
diff --git a/apps/server/package.json b/apps/server/package.json
index 81538a295..4b58cf52c 100644
--- a/apps/server/package.json
+++ b/apps/server/package.json
@@ -25,7 +25,8 @@
"docker-start-rootless-alpine": "pnpm docker-build-rootless-alpine && docker run -p 8081:8080 triliumnext-rootless-alpine"
},
"dependencies": {
- "better-sqlite3": "12.4.1"
+ "better-sqlite3": "12.4.1",
+ "node-html-parser": "7.0.1"
},
"devDependencies": {
"@anthropic-ai/sdk": "0.64.0",
@@ -49,7 +50,6 @@
"@types/html": "1.0.4",
"@types/ini": "4.1.1",
"@types/js-yaml": "4.0.9",
- "@types/jsdom": "21.1.7",
"@types/mime-types": "3.0.1",
"@types/multer": "2.0.0",
"@types/safe-compare": "1.1.2",
@@ -105,7 +105,6 @@
"is-svg": "6.1.0",
"jimp": "1.6.0",
"js-yaml": "4.1.0",
- "jsdom": "26.1.0",
"marked": "16.3.0",
"mime-types": "3.0.1",
"multer": "2.0.2",
diff --git a/apps/server/scripts/build.ts b/apps/server/scripts/build.ts
index 84862d060..d2ed99ee2 100644
--- a/apps/server/scripts/build.ts
+++ b/apps/server/scripts/build.ts
@@ -11,7 +11,6 @@ async function main() {
// Copy node modules dependencies
build.copyNodeModules([ "better-sqlite3", "bindings", "file-uri-to-path" ]);
- build.copy("/node_modules/jsdom/lib/jsdom/living/xhr/xhr-sync-worker.js", "xhr-sync-worker.js");
build.copy("/node_modules/ckeditor5/dist/ckeditor5-content.css", "ckeditor5-content.css");
// Integrate the client.
diff --git a/apps/server/src/becca/similarity.spec.ts b/apps/server/src/becca/similarity.spec.ts
new file mode 100644
index 000000000..4809e1f13
--- /dev/null
+++ b/apps/server/src/becca/similarity.spec.ts
@@ -0,0 +1,20 @@
+import { trimIndentation } from "@triliumnext/commons";
+import { buildNote } from "../test/becca_easy_mocking";
+import { buildRewardMap } from "./similarity";
+
+describe("buildRewardMap", () => {
+ it("calculates heading rewards", () => {
+ const note = buildNote({
+ content: trimIndentation`\
+
Heading 1
+ Heading 2
+ Lorem ipsum dolor sit amet, consectetur adipiscing elit. Integer eget purus et eros faucibus dignissim. Vestibulum lacinia urna quis eleifend consectetur. Aenean elementum pellentesque ultrices. Donec tincidunt, felis vel pretium suscipit, nibh lorem gravida est, quis tincidunt metus nibh a tortor. Aenean erat libero, faucibus ac mattis non, imperdiet eget nunc. Pellentesque aliquam molestie nibh eu interdum. Sed augue velit, varius id lacinia ut, dictum in dolor. Praesent posuere quam vel porta eleifend. Nullam porta tempus convallis. Aliquam auctor dui nec consectetur suscipit. Mauris laoreet commodo dapibus. Donec sodales justo velit, at placerat nulla cursus sit amet. Aliquam erat volutpat. Donec nec mauris iaculis, ullamcorper lectus et, feugiat arcu. Nunc vel ligula quis lectus efficitur porta non at nulla.
+ Heading 3
+ `
+ });
+ const map = buildRewardMap(note);
+ for (const key of [ "new", "note", "heading", "1", "2", "3" ]) {
+ expect(typeof map.get(key)).toStrictEqual("number");
+ }
+ });
+});
diff --git a/apps/server/src/becca/similarity.ts b/apps/server/src/becca/similarity.ts
index dc0a4c3f8..10a0e706d 100644
--- a/apps/server/src/becca/similarity.ts
+++ b/apps/server/src/becca/similarity.ts
@@ -2,7 +2,7 @@ import becca from "./becca.js";
import log from "../services/log.js";
import beccaService from "./becca_service.js";
import dateUtils from "../services/date_utils.js";
-import { JSDOM } from "jsdom";
+import { parse } from "node-html-parser";
import type BNote from "./entities/bnote.js";
import { SimilarNote } from "@triliumnext/commons";
@@ -44,7 +44,7 @@ function filterUrlValue(value: string) {
.replace(/(\.net|\.com|\.org|\.info|\.edu)/gi, "");
}
-function buildRewardMap(note: BNote) {
+export function buildRewardMap(note: BNote) {
// Need to use Map instead of object: https://github.com/zadam/trilium/issues/1895
const map = new Map();
@@ -123,10 +123,12 @@ function buildRewardMap(note: BNote) {
if (note.type === "text" && note.isDecrypted) {
const content = note.getContent();
- const dom = new JSDOM(content);
+ if (typeof content !== "string") return map;
+
+ const dom = parse(content);
const addHeadingsToRewardMap = (elName: string, rewardFactor: number) => {
- for (const el of dom.window.document.querySelectorAll(elName)) {
+ for (const el of dom.querySelectorAll(elName)) {
addToRewardMap(el.textContent, rewardFactor);
}
};
diff --git a/apps/server/src/routes/api/clipper.spec.ts b/apps/server/src/routes/api/clipper.spec.ts
new file mode 100644
index 000000000..1efa6915e
--- /dev/null
+++ b/apps/server/src/routes/api/clipper.spec.ts
@@ -0,0 +1,50 @@
+import { BNote } from "../../services/backend_script_entrypoint";
+import { buildNote } from "../../test/becca_easy_mocking";
+import { processContent } from "./clipper";
+
+let note!: BNote;
+
+describe("processContent", () => {
+ beforeAll(() => {
+ note = buildNote({});
+ note.saveAttachment = () => {};
+ vi.mock("../../services/image.js", () => ({
+ default: {
+ saveImageToAttachment() {
+ return {
+ attachmentId: "foo",
+ title: "encodedTitle",
+ }
+ }
+ }
+ }));
+ });
+
+ it("processes basic note", () => {
+ const processed = processContent([], note, "Hello world.
");
+ expect(processed).toStrictEqual("Hello world.
")
+ });
+
+ it("processes plain text", () => {
+ const processed = processContent([], note, "Hello world.");
+ expect(processed).toStrictEqual("Hello world.
")
+ });
+
+ it("replaces images", () => {
+ const processed = processContent(
+ [{"imageId":"OKZxZA3MonZJkwFcEhId","src":"inline.png","dataUrl":"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAkAAAAQCAYAAADESFVDAAAAF0lEQVQoU2P8DwQMBADjqKLRIGAgKggAzHs/0SoYCGwAAAAASUVORK5CYII="}],
+ note, ` `
+ );
+ expect(processed).toStrictEqual(` `);
+ });
+
+ it("skips over non-data images", () => {
+ for (const url of [ "foo", "" ]) {
+ const processed = processContent(
+ [{"imageId":"OKZxZA3MonZJkwFcEhId","src":"inline.png","dataUrl": url}],
+ note, ` `
+ );
+ expect(processed).toStrictEqual(` `);
+ }
+ });
+});
diff --git a/apps/server/src/routes/api/clipper.ts b/apps/server/src/routes/api/clipper.ts
index 2535a26e2..133c35a88 100644
--- a/apps/server/src/routes/api/clipper.ts
+++ b/apps/server/src/routes/api/clipper.ts
@@ -1,5 +1,5 @@
import type { Request } from "express";
-import jsdom from "jsdom";
+import { parse } from "node-html-parser";
import path from "path";
import type BNote from "../../becca/entities/bnote.js";
@@ -16,7 +16,6 @@ import log from "../../services/log.js";
import noteService from "../../services/notes.js";
import utils from "../../services/utils.js";
import ws from "../../services/ws.js";
-const { JSDOM } = jsdom;
interface Image {
src: string;
@@ -147,7 +146,7 @@ async function createNote(req: Request) {
};
}
-function processContent(images: Image[], note: BNote, content: string) {
+export function processContent(images: Image[], note: BNote, content: string) {
let rewrittenContent = htmlSanitizer.sanitize(content);
if (images) {
@@ -181,10 +180,10 @@ function processContent(images: Image[], note: BNote, content: string) {
rewrittenContent = `${rewrittenContent}
`;
}
// Create a JSDOM object from the existing HTML content
- const dom = new JSDOM(rewrittenContent);
+ const dom = parse(rewrittenContent);
// Get the content inside the body tag and serialize it
- rewrittenContent = dom.window.document.body.innerHTML;
+ rewrittenContent = dom.innerHTML ?? "";
return rewrittenContent;
}
diff --git a/apps/server/src/routes/api/note_map.spec.ts b/apps/server/src/routes/api/note_map.spec.ts
new file mode 100644
index 000000000..0ab5d1d2d
--- /dev/null
+++ b/apps/server/src/routes/api/note_map.spec.ts
@@ -0,0 +1,99 @@
+import { trimIndentation } from "@triliumnext/commons";
+import { buildNote, buildNotes } from "../../test/becca_easy_mocking";
+import note_map from "./note_map";
+
+describe("Note map service", () => {
+ it("correctly identifies backlinks", () => {
+ const note = buildNote({ id: "dUtgloZIckax", title: "Backlink text" });
+ buildNotes([
+ {
+ title: "First",
+ id: "first",
+ "~internalLink": "dUtgloZIckax",
+ content: trimIndentation`\
+
+ The quick brownie
+
+
+
+ Backlink text
+
+
+
+
+
+ `
+ },
+ {
+ title: "Second",
+ id: "second",
+ "~internalLink": "dUtgloZIckax",
+ content: trimIndentation`\
+
+
+ Backlink text
+
+
+
+
+ First
+
+
+
+
+ Second
+
+
+ `
+ }
+ ]);
+
+ const backlinksResponse = note_map.getBacklinks({
+ params: {
+ noteId: note.noteId
+ }
+ } as any);
+ expect(backlinksResponse).toMatchObject([
+ {
+ excerpts: [
+ trimIndentation`\
+
+ The quick brownie
+
+
+
+ Backlink text
+
+
+
+ ${" "}
+
+
`
+ ],
+ noteId: "first",
+ },
+ {
+ excerpts: [
+ trimIndentation`\
+ `
+ ],
+ noteId: "second"
+ }
+ ]);
+ });
+});
diff --git a/apps/server/src/routes/api/note_map.ts b/apps/server/src/routes/api/note_map.ts
index 3a2bed5b2..473388af0 100644
--- a/apps/server/src/routes/api/note_map.ts
+++ b/apps/server/src/routes/api/note_map.ts
@@ -1,10 +1,10 @@
"use strict";
import becca from "../../becca/becca.js";
-import { JSDOM } from "jsdom";
import type BNote from "../../becca/entities/bnote.js";
import type BAttribute from "../../becca/entities/battribute.js";
import type { Request } from "express";
+import { HTMLElement, parse, TextNode } from "node-html-parser";
import { BacklinkCountResponse, BacklinksResponse } from "@triliumnext/commons";
interface TreeLink {
@@ -241,25 +241,26 @@ function updateDescendantCountMapForSearch(noteIdToDescendantCountMap: Record 0) {
- images[0]?.parentNode?.removeChild(images[0]);
+ for (const image of images) {
+ image.remove();
}
}
const EXCERPT_CHAR_LIMIT = 200;
-type ElementOrText = Element | Text;
+type ElementOrText = HTMLElement | TextNode;
-function findExcerpts(sourceNote: BNote, referencedNoteId: string) {
+export function findExcerpts(sourceNote: BNote, referencedNoteId: string) {
const html = sourceNote.getContent();
- const document = new JSDOM(html).window.document;
+ const document = parse(html.toString());
const excerpts: string[] = [];
removeImages(document);
for (const linkEl of document.querySelectorAll("a")) {
+ console.log("Got ", linkEl.innerHTML);
const href = linkEl.getAttribute("href");
if (!href || !href.endsWith(referencedNoteId)) {
@@ -270,8 +271,8 @@ function findExcerpts(sourceNote: BNote, referencedNoteId: string) {
let centerEl: HTMLElement = linkEl;
- while (centerEl.tagName !== "BODY" && centerEl.parentElement && (centerEl.parentElement?.textContent?.length || 0) <= EXCERPT_CHAR_LIMIT) {
- centerEl = centerEl.parentElement;
+ while (centerEl.tagName !== "BODY" && centerEl.parentNode && (centerEl.parentNode?.textContent?.length || 0) <= EXCERPT_CHAR_LIMIT) {
+ centerEl = centerEl.parentNode;
}
const excerptEls: ElementOrText[] = [centerEl];
@@ -282,7 +283,7 @@ function findExcerpts(sourceNote: BNote, referencedNoteId: string) {
while (excerptLength < EXCERPT_CHAR_LIMIT) {
let added = false;
- const prev: Element | null = left.previousElementSibling;
+ const prev: HTMLElement | null = left.previousElementSibling;
if (prev) {
const prevText = prev.textContent || "";
@@ -290,7 +291,7 @@ function findExcerpts(sourceNote: BNote, referencedNoteId: string) {
if (prevText.length + excerptLength > EXCERPT_CHAR_LIMIT) {
const prefix = prevText.substr(prevText.length - (EXCERPT_CHAR_LIMIT - excerptLength));
- const textNode = document.createTextNode(`…${prefix}`);
+ const textNode = new TextNode(`…${prefix}`);
excerptEls.unshift(textNode);
break;
@@ -302,7 +303,7 @@ function findExcerpts(sourceNote: BNote, referencedNoteId: string) {
added = true;
}
- const next: Element | null = right.nextElementSibling;
+ const next: HTMLElement | null = right.nextElementSibling;
if (next) {
const nextText = next.textContent;
@@ -310,7 +311,7 @@ function findExcerpts(sourceNote: BNote, referencedNoteId: string) {
if (nextText && nextText.length + excerptLength > EXCERPT_CHAR_LIMIT) {
const suffix = nextText.substr(nextText.length - (EXCERPT_CHAR_LIMIT - excerptLength));
- const textNode = document.createTextNode(`${suffix}…`);
+ const textNode = new TextNode(`${suffix}…`);
excerptEls.push(textNode);
break;
@@ -327,7 +328,7 @@ function findExcerpts(sourceNote: BNote, referencedNoteId: string) {
}
}
- const excerptWrapper = document.createElement("div");
+ const excerptWrapper = new HTMLElement("div", {});
excerptWrapper.classList.add("ck-content");
excerptWrapper.classList.add("backlink-excerpt");
diff --git a/apps/server/src/share/content_renderer.spec.ts b/apps/server/src/share/content_renderer.spec.ts
index 85d1c9dde..8f3f70622 100644
--- a/apps/server/src/share/content_renderer.spec.ts
+++ b/apps/server/src/share/content_renderer.spec.ts
@@ -1,7 +1,88 @@
import { describe, it, expect } from "vitest";
-import { renderCode, type Result } from "./content_renderer.js";
+import { getContent, renderCode, type Result } from "./content_renderer.js";
+import { trimIndentation } from "@triliumnext/commons";
+import { buildShareNote, buildShareNotes } from "../test/shaca_mocking.js";
describe("content_renderer", () => {
+ beforeAll(() => {
+ vi.mock("../becca/becca_loader.js", () => ({
+ default: {
+ load: vi.fn(),
+ loaded: Promise.resolve()
+ }
+ }));
+ });
+
+ it("Reports protected notes not being renderable", () => {
+ const note = buildShareNote({ isProtected: true });
+ const result = getContent(note);
+ expect(result.content).toStrictEqual("Protected note cannot be displayed
");
+ });
+
+ describe("Text note", () => {
+ it("parses simple note", () => {
+ const content = trimIndentation`\
+
+
+
+
+
+ Welcome to Trilium Notes!
+
+
`;
+ const note = buildShareNote({ content });
+ const result = getContent(note);
+ expect(result.content).toStrictEqual(content);
+ });
+
+ it("handles attachment link", () => {
+ const content = trimIndentation`\
+ Test
+
+
+ 5863845791835102555.mp4
+
+
+
+ `;
+ const note = buildShareNote({
+ content,
+ attachments: [ { id: "q14s2Id7V6pp", title: "5863845791835102555.mp4" } ]
+ });
+ const result = getContent(note);
+ expect(result.content).toStrictEqual(trimIndentation`\
+ Test
+
+ 5863845791835102555.mp4
+
+
+ `);
+ });
+
+ it("renders included notes", () => {
+ buildShareNotes([
+ { id: "subnote1", content: `Foo
Bar
` },
+ { id: "subnote2", content: `Baz ` }
+ ]);
+ const note = buildShareNote({
+ id: "note1",
+ content: trimIndentation`\
+ Before
+
+
+ After
+ `
+ });
+ const result = getContent(note);
+ expect(result.content).toStrictEqual(trimIndentation`\
+ Before
+ Foo
Bar
+ Baz
+ After
+ `);
+ });
+ });
+
describe("renderCode", () => {
it("identifies empty content", () => {
const emptyResult: Result = {
diff --git a/apps/server/src/share/content_renderer.ts b/apps/server/src/share/content_renderer.ts
index c12e7887f..83ddfde2e 100644
--- a/apps/server/src/share/content_renderer.ts
+++ b/apps/server/src/share/content_renderer.ts
@@ -1,4 +1,4 @@
-import { JSDOM } from "jsdom";
+import { parse, HTMLElement, TextNode } from "node-html-parser";
import shaca from "./shaca/shaca.js";
import assetPath from "../services/asset_path.js";
import shareRoot from "./share_root.js";
@@ -16,7 +16,7 @@ export interface Result {
isEmpty?: boolean;
}
-function getContent(note: SNote) {
+export function getContent(note: SNote) {
if (note.isProtected) {
return {
header: "",
@@ -66,7 +66,8 @@ function renderIndex(result: Result) {
}
function renderText(result: Result, note: SNote) {
- const document = new JSDOM(result.content || "").window.document;
+ if (typeof result.content !== "string") return;
+ const document = parse(result.content || "");
// Process include notes.
for (const includeNoteEl of document.querySelectorAll("section.include-note")) {
@@ -79,11 +80,13 @@ function renderText(result: Result, note: SNote) {
const includedResult = getContent(note);
if (typeof includedResult.content !== "string") continue;
- const includedDocument = new JSDOM(includedResult.content).window.document;
- includeNoteEl.replaceWith(includedDocument.body);
+ const includedDocument = parse(includedResult.content).childNodes;
+ if (includedDocument) {
+ includeNoteEl.replaceWith(...includedDocument);
+ }
}
- result.isEmpty = document.body.textContent?.trim().length === 0 && document.querySelectorAll("img").length === 0;
+ result.isEmpty = document.textContent?.trim().length === 0 && document.querySelectorAll("img").length === 0;
if (!result.isEmpty) {
for (const linkEl of document.querySelectorAll("a")) {
@@ -99,7 +102,7 @@ function renderText(result: Result, note: SNote) {
}
}
- result.content = document.body.innerHTML;
+ result.content = document.innerHTML ?? "";
if (result.content.includes(``)) {
result.header += `
@@ -120,7 +123,7 @@ document.addEventListener("DOMContentLoaded", function() {
}
}
-function handleAttachmentLink(linkEl: HTMLAnchorElement, href: string) {
+function handleAttachmentLink(linkEl: HTMLElement, href: string) {
const linkRegExp = /attachmentId=([a-zA-Z0-9_]+)/g;
let attachmentMatch;
if ((attachmentMatch = linkRegExp.exec(href))) {
@@ -131,7 +134,8 @@ function handleAttachmentLink(linkEl: HTMLAnchorElement, href: string) {
linkEl.setAttribute("href", `api/attachments/${attachmentId}/download`);
linkEl.classList.add(`attachment-link`);
linkEl.classList.add(`role-${attachment.role}`);
- linkEl.innerText = attachment.title;
+ linkEl.childNodes.length = 0;
+ linkEl.appendChild(new TextNode(attachment.title));
} else {
linkEl.removeAttribute("href");
}
@@ -164,11 +168,8 @@ export function renderCode(result: Result) {
if (typeof result.content !== "string" || !result.content?.trim()) {
result.isEmpty = true;
} else {
- const document = new JSDOM().window.document;
-
- const preEl = document.createElement("pre");
- preEl.appendChild(document.createTextNode(result.content));
-
+ const preEl = new HTMLElement("pre", {});
+ preEl.appendChild(new TextNode(result.content));
result.content = preEl.outerHTML;
}
}
diff --git a/apps/server/src/test/becca_easy_mocking.ts b/apps/server/src/test/becca_easy_mocking.ts
new file mode 100644
index 000000000..6df198a5a
--- /dev/null
+++ b/apps/server/src/test/becca_easy_mocking.ts
@@ -0,0 +1,92 @@
+import utils from "../services/utils.js";
+import BNote from "../becca/entities/bnote.js";
+import BAttribute from "../becca/entities/battribute.js";
+
+type AttributeDefinitions = { [key in `#${string}`]: string; };
+type RelationDefinitions = { [key in `~${string}`]: string; };
+
+interface NoteDefinition extends AttributeDefinitions, RelationDefinitions {
+ id?: string | undefined;
+ title?: string;
+ content?: string;
+}
+
+/**
+ * Creates the given notes with the given title and optionally one or more attributes.
+ *
+ * For a label to be created, simply pass on a key prefixed with `#` and any desired value.
+ *
+ * The notes and attributes will be injected in the froca.
+ *
+ * @param notes
+ * @returns an array containing the IDs of the created notes.
+ * @example
+ * buildNotes([
+ * { title: "A", "#startDate": "2025-05-05" },
+ * { title: "B", "#startDate": "2025-05-07" }
+ * ]);
+ */
+export function buildNotes(notes: NoteDefinition[]) {
+ const ids: string[] = [];
+
+ for (const noteDef of notes) {
+ ids.push(buildNote(noteDef).noteId);
+ }
+
+ return ids;
+}
+
+export function buildNote(noteDef: NoteDefinition) {
+ const note = new BNote({
+ noteId: noteDef.id ?? utils.randomString(12),
+ title: noteDef.title ?? "New note",
+ type: "text",
+ mime: "text/html",
+ isProtected: false,
+ blobId: ""
+ });
+
+ // Handle content.
+ if (noteDef.content) {
+ note.getContent = () => noteDef.content!;
+ }
+
+ // Handle labels and relations.
+ let position = 0;
+ for (const [ key, value ] of Object.entries(noteDef)) {
+ const attributeId = utils.randomString(12);
+ const name = key.substring(1);
+
+ let attribute: BAttribute | null = null;
+ if (key.startsWith("#")) {
+ attribute = new BAttribute({
+ noteId: note.noteId,
+ attributeId,
+ type: "label",
+ name,
+ value,
+ position,
+ isInheritable: false
+ });
+ }
+
+ if (key.startsWith("~")) {
+ attribute = new BAttribute({
+ noteId: note.noteId,
+ attributeId,
+ type: "relation",
+ name,
+ value,
+ position,
+ isInheritable: false
+ });
+ }
+
+ if (!attribute) {
+ continue;
+ }
+
+ position++;
+ }
+ return note;
+}
diff --git a/apps/server/src/test/shaca_mocking.ts b/apps/server/src/test/shaca_mocking.ts
new file mode 100644
index 000000000..8b2068a0e
--- /dev/null
+++ b/apps/server/src/test/shaca_mocking.ts
@@ -0,0 +1,135 @@
+import utils from "../services/utils.js";
+import SAttachment from "../share/shaca/entities/sattachment.js";
+import SAttribute from "../share/shaca/entities/sattribute.js";
+import SNote from "../share/shaca/entities/snote.js";
+import shaca from "../share/shaca/shaca.js";
+
+type AttributeDefinitions = { [key in `#${string}`]: string; };
+type RelationDefinitions = { [key in `~${string}`]: string; };
+
+interface AttachementDefinition {
+ id?: string;
+ role?: string;
+ mime?: string;
+ title?: string;
+}
+
+interface NoteDefinition extends AttributeDefinitions, RelationDefinitions {
+ id?: string | undefined;
+ title?: string;
+ content?: string | Buffer;
+ children?: NoteDefinition[];
+ attachments?: AttachementDefinition[];
+ isProtected?: boolean;
+}
+
+/**
+ * Creates the given notes with the given title and optionally one or more attributes.
+ *
+ * For a label to be created, simply pass on a key prefixed with `#` and any desired value.
+ *
+ * The notes and attributes will be injected in the froca.
+ *
+ * @param notes
+ * @returns an array containing the IDs of the created notes.
+ * @example
+ * buildShareNotes([
+ * { title: "A", "#startDate": "2025-05-05" },
+ * { title: "B", "#startDate": "2025-05-07" }
+ * ]);
+ */
+export function buildShareNotes(notes: NoteDefinition[]) {
+ const ids: string[] = [];
+
+ for (const noteDef of notes) {
+ ids.push(buildShareNote(noteDef).noteId);
+ }
+
+ return ids;
+}
+
+export function buildShareNote(noteDef: NoteDefinition) {
+ const blobId = "foo";
+ const note = new SNote([
+ noteDef.id ?? utils.randomString(12),
+ noteDef.title ?? "New note",
+ "text",
+ "text/html",
+ blobId,
+ new Date().toUTCString(), // utcDateModified
+ !!noteDef.isProtected
+ ]);
+ shaca.notes[note.noteId] = note;
+
+ // Handle content
+ if (noteDef.content) {
+ note.getContent = () => {
+ if (noteDef.isProtected) return undefined;
+ return noteDef.content;
+ };
+ }
+
+ // Handle attachments.
+ if (noteDef.attachments) {
+ for (const attachmentDef of noteDef.attachments) {
+ new SAttachment([
+ attachmentDef.id ?? utils.randomString(12),
+ note.noteId,
+ attachmentDef.role ?? "file",
+ attachmentDef.mime ?? "application/blob",
+ attachmentDef.title ?? "New attachment",
+ blobId,
+ new Date().toUTCString(), // utcDateModified
+ ]);
+ }
+ }
+
+ // Handle children.
+ if (noteDef.children) {
+ for (const childDef of noteDef.children) {
+ const childNote = buildShareNote(childDef);
+
+ // TODO: Create corresponding SBranch.
+ }
+ }
+
+ // Handle labels & relations
+ let position = 0;
+ for (const [ key, value ] of Object.entries(noteDef)) {
+ const attributeId = utils.randomString(12);
+ const name = key.substring(1);
+
+ let attribute: SAttribute | null = null;
+ if (key.startsWith("#")) {
+ attribute = new SAttribute([
+ attributeId,
+ note.noteId,
+ "label",
+ name,
+ value,
+ false, // isInheritable
+ position // position
+ ]);
+ }
+
+ if (key.startsWith("~")) {
+ attribute = new SAttribute([
+ attributeId,
+ note.noteId,
+ "relation",
+ name,
+ value,
+ false, // isInheritable
+ position // position
+ ]);
+ }
+
+ if (!attribute) {
+ continue;
+ }
+
+ shaca.attributes[attributeId] = attribute;
+ position++;
+ }
+ return note;
+}
diff --git a/package.json b/package.json
index 5ed05401d..db1abf06c 100644
--- a/package.json
+++ b/package.json
@@ -53,7 +53,6 @@
"eslint-plugin-react-hooks": "5.2.0",
"happy-dom": "~19.0.0",
"jiti": "2.6.0",
- "jsdom": "~26.1.0",
"jsonc-eslint-parser": "^2.1.0",
"react-refresh": "^0.17.0",
"rollup-plugin-webpack-stats": "2.1.5",
diff --git a/packages/express-partial-content/vite.config.ts b/packages/express-partial-content/vite.config.ts
index e813c9ac1..fc85d4a52 100644
--- a/packages/express-partial-content/vite.config.ts
+++ b/packages/express-partial-content/vite.config.ts
@@ -8,7 +8,7 @@ export default defineConfig(() => ({
test: {
watch: false,
globals: true,
- environment: 'jsdom',
+ environment: 'happy-dom',
include: ['{src,tests}/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'],
reporters: ['default'],
coverage: {
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index bc9c63470..b73a74d4e 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -88,9 +88,6 @@ importers:
jiti:
specifier: 2.6.0
version: 2.6.0
- jsdom:
- specifier: ~26.1.0
- version: 26.1.0(bufferutil@4.0.9)(utf-8-validate@6.0.5)
jsonc-eslint-parser:
specifier: ^2.1.0
version: 2.4.1
@@ -449,6 +446,9 @@ importers:
better-sqlite3:
specifier: 12.4.1
version: 12.4.1
+ node-html-parser:
+ specifier: 7.0.1
+ version: 7.0.1
devDependencies:
'@anthropic-ai/sdk':
specifier: 0.64.0
@@ -513,9 +513,6 @@ importers:
'@types/js-yaml':
specifier: 4.0.9
version: 4.0.9
- '@types/jsdom':
- specifier: 21.1.7
- version: 21.1.7
'@types/mime-types':
specifier: 3.0.1
version: 3.0.1
@@ -681,9 +678,6 @@ importers:
js-yaml:
specifier: 4.1.0
version: 4.1.0
- jsdom:
- specifier: 26.1.0
- version: 26.1.0(bufferutil@4.0.9)(utf-8-validate@6.0.5)
marked:
specifier: 16.3.0
version: 16.3.0
@@ -4809,9 +4803,6 @@ packages:
'@types/js-yaml@4.0.9':
resolution: {integrity: sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==}
- '@types/jsdom@21.1.7':
- resolution: {integrity: sha512-yOriVnggzrnQ3a9OKOCxaVuSug3w3/SbOj5i7VwXWZEyUNl3bLF9V3MfxGbZKuwqJOQyRfqXyROBB1CoZLFWzA==}
-
'@types/json-schema@7.0.15':
resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
@@ -9968,6 +9959,9 @@ packages:
node-html-parser@6.1.13:
resolution: {integrity: sha512-qIsTMOY4C/dAa5Q5vsobRpOOvPfC4pB61UVW2uSwZNUp0QU/jCekTal1vMmbO0DgdHeLUJpv/ARmDqErVxA3Sg==}
+ node-html-parser@7.0.1:
+ resolution: {integrity: sha512-KGtmPY2kS0thCWGK0VuPyOS+pBKhhe8gXztzA2ilAOhbUbxa9homF1bOyKvhGzMLXUoRds9IOmr/v5lr/lqNmA==}
+
node-releases@2.0.19:
resolution: {integrity: sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==}
@@ -13942,6 +13936,7 @@ snapshots:
'@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4)
'@csstools/css-tokenizer': 3.0.4
lru-cache: 10.4.3
+ optional: true
'@aws-crypto/crc32@5.2.0':
dependencies:
@@ -14728,6 +14723,8 @@ snapshots:
'@ckeditor/ckeditor5-core': 46.1.1
'@ckeditor/ckeditor5-utils': 46.1.1
ckeditor5: 46.1.1(patch_hash=8331a09d41443b39ea1c784daaccfeb0da4f9065ed556e7de92e9c77edd9eb41)
+ transitivePeerDependencies:
+ - supports-color
'@ckeditor/ckeditor5-code-block@46.1.1(patch_hash=2361d8caad7d6b5bddacc3a3b4aa37dbfba260b1c1b22a450413a79c1bb1ce95)':
dependencies:
@@ -14787,6 +14784,8 @@ snapshots:
'@ckeditor/ckeditor5-utils': 46.1.1
'@ckeditor/ckeditor5-watchdog': 46.1.1
es-toolkit: 1.39.5
+ transitivePeerDependencies:
+ - supports-color
'@ckeditor/ckeditor5-dev-build-tools@43.1.0(@swc/helpers@0.5.17)(tslib@2.8.1)(typescript@5.9.2)':
dependencies:
@@ -14978,6 +14977,8 @@ snapshots:
'@ckeditor/ckeditor5-utils': 46.1.1
ckeditor5: 46.1.1(patch_hash=8331a09d41443b39ea1c784daaccfeb0da4f9065ed556e7de92e9c77edd9eb41)
es-toolkit: 1.39.5
+ transitivePeerDependencies:
+ - supports-color
'@ckeditor/ckeditor5-editor-multi-root@46.1.1':
dependencies:
@@ -15473,6 +15474,8 @@ snapshots:
'@ckeditor/ckeditor5-ui': 46.1.1
'@ckeditor/ckeditor5-utils': 46.1.1
ckeditor5: 46.1.1(patch_hash=8331a09d41443b39ea1c784daaccfeb0da4f9065ed556e7de92e9c77edd9eb41)
+ transitivePeerDependencies:
+ - supports-color
'@ckeditor/ckeditor5-restricted-editing@46.1.1':
dependencies:
@@ -15559,8 +15562,6 @@ snapshots:
'@ckeditor/ckeditor5-ui': 46.1.1
'@ckeditor/ckeditor5-utils': 46.1.1
ckeditor5: 46.1.1(patch_hash=8331a09d41443b39ea1c784daaccfeb0da4f9065ed556e7de92e9c77edd9eb41)
- transitivePeerDependencies:
- - supports-color
'@ckeditor/ckeditor5-special-characters@46.1.1':
dependencies:
@@ -15874,12 +15875,14 @@ snapshots:
dependencies:
'@jridgewell/trace-mapping': 0.3.9
- '@csstools/color-helpers@5.0.2': {}
+ '@csstools/color-helpers@5.0.2':
+ optional: true
'@csstools/css-calc@2.1.3(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)':
dependencies:
'@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4)
'@csstools/css-tokenizer': 3.0.4
+ optional: true
'@csstools/css-color-parser@3.0.9(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)':
dependencies:
@@ -15887,6 +15890,7 @@ snapshots:
'@csstools/css-calc': 2.1.3(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)
'@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4)
'@csstools/css-tokenizer': 3.0.4
+ optional: true
'@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4)':
dependencies:
@@ -19121,12 +19125,6 @@ snapshots:
'@types/js-yaml@4.0.9': {}
- '@types/jsdom@21.1.7':
- dependencies:
- '@types/node': 22.15.21
- '@types/tough-cookie': 4.0.5
- parse5: 7.3.0
-
'@types/json-schema@7.0.15': {}
'@types/jsonfile@6.1.4':
@@ -19333,7 +19331,8 @@ snapshots:
'@types/tmp@0.2.6': {}
- '@types/tough-cookie@4.0.5': {}
+ '@types/tough-cookie@4.0.5':
+ optional: true
'@types/trusted-types@2.0.7':
optional: true
@@ -21259,7 +21258,7 @@ snapshots:
css-select@4.3.0:
dependencies:
boolbase: 1.0.0
- css-what: 6.1.0
+ css-what: 6.2.2
domhandler: 4.3.1
domutils: 2.8.0
nth-check: 2.1.1
@@ -21514,6 +21513,7 @@ snapshots:
dependencies:
'@asamuzakjp/css-color': 3.1.4
rrweb-cssom: 0.8.0
+ optional: true
csstype@3.1.3:
optional: true
@@ -21728,6 +21728,7 @@ snapshots:
dependencies:
whatwg-mimetype: 4.0.0
whatwg-url: 14.2.0
+ optional: true
data-view-buffer@1.0.2:
dependencies:
@@ -23684,6 +23685,7 @@ snapshots:
html-encoding-sniffer@4.0.0:
dependencies:
whatwg-encoding: 3.1.1
+ optional: true
html-escaper@2.0.2: {}
@@ -24421,6 +24423,7 @@ snapshots:
- bufferutil
- supports-color
- utf-8-validate
+ optional: true
jsesc@3.1.0: {}
@@ -25767,6 +25770,11 @@ snapshots:
css-select: 5.2.2
he: 1.2.0
+ node-html-parser@7.0.1:
+ dependencies:
+ css-select: 5.2.2
+ he: 1.2.0
+
node-releases@2.0.19: {}
node-releases@2.0.21: {}
@@ -27806,7 +27814,8 @@ snapshots:
transitivePeerDependencies:
- supports-color
- rrweb-cssom@0.8.0: {}
+ rrweb-cssom@0.8.0:
+ optional: true
run-applescript@7.1.0: {}
@@ -27982,6 +27991,7 @@ snapshots:
saxes@6.0.0:
dependencies:
xmlchars: 2.2.0
+ optional: true
scheduler@0.19.1:
dependencies:
@@ -28875,7 +28885,7 @@ snapshots:
dependencies:
'@trysound/sax': 0.2.0
commander: 7.2.0
- css-select: 5.1.0
+ css-select: 5.2.2
css-tree: 2.3.1
css-what: 6.1.0
csso: 5.0.5
@@ -29122,11 +29132,13 @@ snapshots:
tinyspy@4.0.3: {}
- tldts-core@6.1.86: {}
+ tldts-core@6.1.86:
+ optional: true
tldts@6.1.86:
dependencies:
tldts-core: 6.1.86
+ optional: true
tmp-promise@3.0.3:
dependencies:
@@ -29175,6 +29187,7 @@ snapshots:
tough-cookie@5.1.2:
dependencies:
tldts: 6.1.86
+ optional: true
tr46@0.0.3: {}
@@ -29185,6 +29198,7 @@ snapshots:
tr46@5.1.1:
dependencies:
punycode: 2.3.1
+ optional: true
tree-dump@1.1.0(tslib@2.8.1):
dependencies:
@@ -29807,6 +29821,7 @@ snapshots:
w3c-xmlserializer@5.0.0:
dependencies:
xml-name-validator: 5.0.0
+ optional: true
wait-port@1.1.0:
dependencies:
@@ -29895,7 +29910,8 @@ snapshots:
webidl-conversions@6.1.0: {}
- webidl-conversions@7.0.0: {}
+ webidl-conversions@7.0.0:
+ optional: true
webpack-dev-middleware@7.4.3(webpack@5.101.3(@swc/core@1.11.29(@swc/helpers@0.5.17))(esbuild@0.25.10)):
dependencies:
@@ -30056,6 +30072,7 @@ snapshots:
dependencies:
tr46: 5.1.1
webidl-conversions: 7.0.0
+ optional: true
whatwg-url@5.0.0:
dependencies:
@@ -30211,7 +30228,8 @@ snapshots:
xml-name-validator@3.0.0: {}
- xml-name-validator@5.0.0: {}
+ xml-name-validator@5.0.0:
+ optional: true
xml-parse-from-string@1.0.1: {}
diff --git a/scripts/build-utils.ts b/scripts/build-utils.ts
index 8da5be9d1..6a08f112e 100644
--- a/scripts/build-utils.ts
+++ b/scripts/build-utils.ts
@@ -1,6 +1,6 @@
import { execSync } from "child_process";
import { build as esbuild } from "esbuild";
-import { cpSync, existsSync, rmSync } from "fs";
+import { cpSync, existsSync, rmSync, writeFileSync } from "fs";
import { copySync, emptyDirSync, mkdirpSync } from "fs-extra";
import { join } from "path";
@@ -37,7 +37,7 @@ export default class BuildHelper {
}
async buildBackend(entryPoints: string[]) {
- await esbuild({
+ const result = await esbuild({
entryPoints: entryPoints.map(e => join(this.projectDir, e)),
tsconfig: join(this.projectDir, "tsconfig.app.json"),
platform: "node",
@@ -54,6 +54,7 @@ export default class BuildHelper {
"./xhr-sync-worker.js",
"vite"
],
+ metafile: true,
splitting: false,
loader: {
".css": "text",
@@ -64,6 +65,7 @@ export default class BuildHelper {
},
minify: true
});
+ writeFileSync(join(this.outDir, "meta.json"), JSON.stringify(result.metafile));
}
triggerBuildAndCopyTo(projectToBuild: string, destPath: string) {
diff --git a/tsconfig.base.json b/tsconfig.base.json
index 76d2e73fc..22906a79f 100644
--- a/tsconfig.base.json
+++ b/tsconfig.base.json
@@ -5,7 +5,7 @@
"emitDeclarationOnly": true,
"importHelpers": true,
"isolatedModules": true,
- "lib": ["ES2023"],
+ "lib": ["ES2023", "DOM", "DOM.Iterable"],
"module": "nodenext",
"moduleResolution": "nodenext",
"noEmitOnError": true,