A .docx editor is a megabyte of vendored library. Bundling it would charge that to everyone who wanted a browser, including the people who will never open a Word document in it. So it leaves the build: out of bundled-addons/, out of extraResources, absent from a fresh profile. It arrives the way anyone else's extension does — Settings › Extensions › Community, from the catalogue the gateway builds, and listed on theseus.x/extensions alongside everything else published there. That also means it is signed by the owner of a BNS name rather than by the operator key, which is the right trust story for something that isn't part of the browser. `npm run pack` produces the tarball the publish page takes; the signature needs the publisher name's wallet, so it isn't something the repo can do. The end-to-end test now installs the extension into a throwaway profile the way the community installer would, and asserts up front that a fresh profile doesn't already have it — the bundling is what was being removed, so it is worth a test that would notice it coming back.
124 lines
4.8 KiB
JavaScript
124 lines
4.8 KiB
JavaScript
// Loads the extension's libraries the way the browser does, but under node, so
|
|
// the round-trip can be tested without driving a browser.
|
|
//
|
|
// The only difference from the real thing is where the vendored packages come
|
|
// from: the browser gets them out of vendor/docx-vendor.js, node gets them
|
|
// from node_modules — with mammoth taken from .patched/, the same patched copy
|
|
// that goes into the bundle.
|
|
import { readFileSync } from "node:fs";
|
|
import { createRequire } from "node:module";
|
|
import { fileURLToPath } from "node:url";
|
|
import path from "node:path";
|
|
import vm from "node:vm";
|
|
import { JSDOM } from "jsdom";
|
|
|
|
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
const require = createRequire(import.meta.url);
|
|
export const ADDON = path.resolve(here, "../../../extensions/docx-editor");
|
|
|
|
export function loadAddonLibs() {
|
|
const dom = new JSDOM("<!doctype html><html><body></body></html>");
|
|
globalThis.window = dom.window;
|
|
globalThis.document = dom.window.document;
|
|
globalThis.DOMParser = dom.window.DOMParser;
|
|
globalThis.XMLSerializer = dom.window.XMLSerializer;
|
|
globalThis.Node = dom.window.Node;
|
|
|
|
globalThis.DOCXV = {
|
|
mammoth: require(path.resolve(here, "../.patched/mammoth")),
|
|
docx: require("docx"),
|
|
JSZip: require("jszip"),
|
|
pm: {
|
|
state: require("prosemirror-state"),
|
|
view: null, // not needed outside the browser
|
|
model: require("prosemirror-model"),
|
|
schemaBasic: require("prosemirror-schema-basic"),
|
|
schemaList: require("prosemirror-schema-list"),
|
|
tables: require("prosemirror-tables"),
|
|
history: require("prosemirror-history"),
|
|
commands: require("prosemirror-commands"),
|
|
keymap: require("prosemirror-keymap"),
|
|
inputrules: require("prosemirror-inputrules"),
|
|
dropcursor: null,
|
|
gapcursor: null,
|
|
},
|
|
};
|
|
globalThis.DocxEditor = {};
|
|
|
|
for (const f of ["pkg.js", "schema.js", "read.js", "write.js"]) {
|
|
const src = readFileSync(path.join(ADDON, "lib", f), "utf8");
|
|
vm.runInThisContext(src, { filename: path.join(ADDON, "lib", f) });
|
|
}
|
|
return globalThis.DocxEditor;
|
|
}
|
|
|
|
// A compact, comparable view of a ProseMirror document: node types, the
|
|
// attributes that came from Word, and each text node's marks. Positions and
|
|
// ids are left out so the diff shows content drift and nothing else.
|
|
export function summarise(doc) {
|
|
const MEANINGFUL = ["level", "align", "indent", "lineHeight", "spaceBefore", "spaceAfter",
|
|
"format", "order", "colspan", "rowspan", "noteType", "noteId",
|
|
"alt", "width", "height"];
|
|
function attrs(node) {
|
|
const out = {};
|
|
for (const k of MEANINGFUL) {
|
|
const v = node.attrs && node.attrs[k];
|
|
if (v === undefined || v === null) continue;
|
|
if ((k === "colspan" || k === "rowspan" || k === "order") && v === 1) continue;
|
|
if (k === "indent" && v === 0) continue;
|
|
out[k] = v;
|
|
}
|
|
return out;
|
|
}
|
|
function walk(node) {
|
|
if (node.isText) {
|
|
const marks = node.marks.map((m) => {
|
|
const a = Object.keys(m.attrs || {})
|
|
.filter((k) => m.attrs[k] !== null && m.attrs[k] !== undefined)
|
|
.sort()
|
|
.map((k) => `${k}=${m.attrs[k]}`)
|
|
.join(",");
|
|
return a ? `${m.type.name}(${a})` : m.type.name;
|
|
}).sort();
|
|
return { t: "text", text: node.text, marks };
|
|
}
|
|
const entry = { t: node.type.name };
|
|
const a = attrs(node);
|
|
if (Object.keys(a).length) entry.a = a;
|
|
if (node.type.name === "image") {
|
|
// Compare the bytes by length, not by the whole base64 blob.
|
|
entry.a = Object.assign(entry.a || {}, { srcLen: (node.attrs.src || "").length });
|
|
}
|
|
const kids = [];
|
|
node.forEach((child) => kids.push(walk(child)));
|
|
if (kids.length) entry.c = kids;
|
|
return entry;
|
|
}
|
|
return walk(doc);
|
|
}
|
|
|
|
export function diff(a, b, pathStr = "doc", out = []) {
|
|
const ja = JSON.stringify(a), jb = JSON.stringify(b);
|
|
if (ja === jb) return out;
|
|
if (typeof a !== "object" || typeof b !== "object" || a === null || b === null) {
|
|
out.push(`${pathStr}: ${ja} -> ${jb}`);
|
|
return out;
|
|
}
|
|
if (Array.isArray(a) !== Array.isArray(b)) {
|
|
out.push(`${pathStr}: shape changed`);
|
|
return out;
|
|
}
|
|
if (Array.isArray(a)) {
|
|
if (a.length !== b.length) out.push(`${pathStr}: ${a.length} children -> ${b.length}`);
|
|
for (let i = 0; i < Math.max(a.length, b.length); i++) {
|
|
if (i >= a.length) { out.push(`${pathStr}[${i}]: added ${JSON.stringify(b[i]).slice(0, 120)}`); continue; }
|
|
if (i >= b.length) { out.push(`${pathStr}[${i}]: lost ${JSON.stringify(a[i]).slice(0, 120)}`); continue; }
|
|
diff(a[i], b[i], `${pathStr}[${i}]`, out);
|
|
}
|
|
return out;
|
|
}
|
|
for (const k of new Set([...Object.keys(a), ...Object.keys(b)])) {
|
|
diff(a[k], b[k], `${pathStr}.${k}`, out);
|
|
}
|
|
return out;
|
|
}
|