ci: add oxlint correctness check and resolve its findings (#592)

* refactor: resolve oxlint correctness findings without behavior changes

- Snapshot iterations that mutate their collection with Array.from()
  instead of a spread (live childNodes, self-removing disposers, a Set
  extended inside its own loop).
- Drop empty spread fallbacks, redundant regex escapes, Boolean() casts
  inside negations, a rethrow-only try/catch and statement ternaries.
- Use startsWith/endsWith for anchored literal checks in the extension.
- List U+FE0F explicitly in the mobile attachment emoji class; the
  matched set is unchanged.
- Fix the WeChat copy fixture, where \oint in a template literal lost its
  backslash and the block formula never exercised \oint.
- Document the intentional control-character regexes (filename
  sanitizing and input validation), the throwing provider stream test,
  Astro's generated type reference and the mobile auto-sync finally
  block with narrow inline exceptions.

* ci: run oxlint correctness rules on pull requests

Pin oxlint 1.88.0 and enable only its correctness category across apps/
and packages/, excluding build output, vendored code and the iOS app.
Findings are errors and the script passes --deny-warnings, so CI fails
on any finding. no-unused-vars stays off until unused code is removed
in a dedicated pass.
This commit is contained in:
Dariusz Koryto authored and GitHub committed 2026-10-16 21:57:36 +08:00
1 parent c2ff5ca1b6
commit bec688b91a
40 files changed
+139 -53

No files matched your search

+4 -1
View File
@@ -1,6 +1,6 @@
name: Test
# Runs the non-E2E regression suite, type checks and the web build for every
# Runs the non-E2E regression suite, lint, type checks and the web build for every
# pull request and every push to main, so failures surface before a release
# instead of in the release workflow.
on:
@@ -52,6 +52,9 @@ jobs:
sleep "$((15 * attempt))"
done
- name: Lint
run: bun run lint
- name: Type check
run: bun run typecheck
+26
View File
@@ -0,0 +1,26 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["eslint", "typescript", "unicorn", "oxc"],
"categories": {
"correctness": "error"
},
"rules": {
// Unused code is removed in a dedicated pass before this rule is enforced.
"no-unused-vars": "off"
},
"overrides": [
{
// Print windows deliberately load a preload that exposes no desktop bridge.
"files": ["apps/desktop/src/preload/print.cjs"],
"rules": { "unicorn/no-empty-file": "off" }
}
],
"ignorePatterns": [
"**/dist/**",
"**/vendor/**",
"apps/ios/**",
"apps/site/.astro/**",
"apps/extension/dist-firefox/**",
"packages/plugin-api/dist/**"
]
}
+1
View File
@@ -398,6 +398,7 @@ describe("companion HTTP contracts", () => {
expect((await getCompanionTurn(db, scope, payload.id))).toMatchObject({ status: "failed", response: "partial response" });
});
test("maps provider HTTP 400 without leaking the response body", async () => {
// oxlint-disable-next-line require-yield -- the provider stream fails before its first chunk
const { request } = fixture({ stream: async () => ({ totalUsage: Promise.resolve({}), fullStream: (async function* () {
const error = new Error("Invalid schema sk-secret");
error.statusCode = 400;
+2 -2
View File
@@ -45,12 +45,12 @@ const mcpRequest = (payload, headers = {}) => ({
const modernMcpRequest = (payload, headers = {}) => {
const params = {
...(payload.params ?? {}),
...payload.params,
_meta: {
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
"io.modelcontextprotocol/clientInfo": { name: "edgeever-test", version: "1.0.0" },
"io.modelcontextprotocol/clientCapabilities": {},
...(payload.params?._meta ?? {}),
...payload.params?._meta,
},
};
const name = payload.method === "resources/read" ? params.uri : params.name;
+1
View File
@@ -129,6 +129,7 @@ export const validateAttachmentUpload = (size: number) => {
export const normalizeFilename = (filename: string) => filename
.trim()
.replace(/[\\/]/g, "-")
// oxlint-disable-next-line no-control-regex -- strips control characters from resource filenames
.replace(/[\u0000-\u001f\u007f]/g, "")
.slice(0, 160);
@@ -115,7 +115,8 @@ const runNpm = (args, cwd, { executablePath = process.execPath, spawnImpl = spaw
child.once("error", (error) => { clearTimeout(timer); reject(error); });
child.once("exit", (code) => {
clearTimeout(timer);
code === 0 ? resolve() : reject(new Error("adapter_install_failed"));
if (code === 0) resolve();
else reject(new Error("adapter_install_failed"));
});
});
+1
View File
@@ -34,6 +34,7 @@ export const resourceIdFromRequest = (requestUrl) => {
const normalizeDownloadFilename = (filename) => filename
.trim()
.replace(/[\\/]/g, "-")
// oxlint-disable-next-line no-control-regex -- strips control characters from resource filenames
.replace(/[\u0000-\u001f\u007f]/g, "")
.slice(0, 160);
@@ -13,7 +13,9 @@ export const normalizeStagedResourceMetadataInput = (input) => {
const type = typeof input.type === "string" && input.type.trim() ? input.type.trim() : "application/octet-stream";
const size = Number(input.size);
if (!memoId || memoId.length > MAX_STAGED_RESOURCE_MEMO_ID_LENGTH) throw new Error("Invalid staged resource memo id");
// oxlint-disable-next-line no-control-regex -- rejects control characters in staged resource names
if (!name || name.length > MAX_STAGED_RESOURCE_NAME_LENGTH || /[\u0000-\u001f\u007f]/.test(name)) throw new Error("Invalid staged resource name");
// oxlint-disable-next-line no-control-regex -- rejects control characters in staged resource types
if (type.length > MAX_STAGED_RESOURCE_TYPE_LENGTH || /[\u0000-\u001f\u007f]/.test(type)) throw new Error("Invalid staged resource type");
if (!Number.isSafeInteger(size) || size <= 0 || size > MAX_STAGED_RESOURCE_BYTES) {
throw new Error("Staged resource must be between 1 byte and 1 GiB");
+2 -2
View File
@@ -235,7 +235,7 @@ test("Douban short comments bind to the chosen own permalink and do not capture
test("Jike declared content cannot lose its entire text or links, and media cannot be replaced by duplicates",()=>{
for(const mode of ["remove","hidden"]){const doc=fixture("jike-grid"),text=doc.querySelector('.text');mode==="remove"?text.remove():text.setAttribute('hidden','');expect(read("jike-grid",doc)).toEqual({ok:false,reason:"incomplete"});}
for(const mode of ["remove","hidden"]){const doc=fixture("jike-grid"),text=doc.querySelector('.text');if(mode==="remove")text.remove();else text.setAttribute('hidden','');expect(read("jike-grid",doc)).toEqual({ok:false,reason:"incomplete"});}
const links=fixture("jike-long");for(const link of Array.from(links.querySelectorAll('.text a[href]')))link.remove();expect(read("jike-long",links)).toEqual({ok:false,reason:"incomplete"});
const wrong=fixture("jike-long");wrong.querySelector('.text a[href]').setAttribute('href','https://m.okjike.com/originalPosts/6715e824d9dc81724c2ec74d');expect(read("jike-long",wrong)).toEqual({ok:false,reason:"incomplete"});
const images=fixture("jike-grid"),first=images.querySelector('.image-item img').getAttribute('src');for(const img of Array.from(images.querySelectorAll('.image-item img')))img.setAttribute('src',first);expect(read("jike-grid",images)).toEqual({ok:false,reason:"incomplete"});
@@ -244,7 +244,7 @@ test("Jike declared content cannot lose its entire text or links, and media cann
test("hidden visual date and rating fields are omitted while optional short-comment time does not gate text",()=>{
const doc=fixture("douban-book");doc.querySelector('.main-meta').textContent='';const time=doc.createElement('span');time.setAttribute('content','2024-07-31');time.setAttribute('hidden','');time.textContent='2024-07-31 17:37:35';doc.querySelector('.main-meta').appendChild(time);
const rating=doc.querySelector('.main-title-rating');rating.setAttribute('title','HIDDEN RATING');rating.setAttribute('hidden','');const result=read('douban-book',doc);expect(result.clip.publishedAt).toBeUndefined();expect(result.clip.markdown).not.toContain('HIDDEN RATING');
for(const action of ['remove','href']){const list=fixture('douban-comments'),card=list.querySelector('.comment-item'),time=card.querySelector('.comment-time');action==='remove'?time.remove():time.removeAttribute('href');const target=doubanCommentTarget(card,'https://book.douban.com/subject/36864764/comments/');const saved=readDouban(list,target,'https://book.douban.com/subject/36864764/comments/');expect(saved.ok).toBe(true);expect(saved.clip.markdown).toContain('/subject/36864764/comments/#c-4237804504');}
for(const action of ['remove','href']){const list=fixture('douban-comments'),card=list.querySelector('.comment-item'),time=card.querySelector('.comment-time');if(action==='remove')time.remove();else time.removeAttribute('href');const target=doubanCommentTarget(card,'https://book.douban.com/subject/36864764/comments/');const saved=readDouban(list,target,'https://book.douban.com/subject/36864764/comments/');expect(saved.ok).toBe(true);expect(saved.clip.markdown).toContain('/subject/36864764/comments/#c-4237804504');}
});
const jikeFeedUrl='https://m.okjike.com/topics/63579abb6724cc583b9bba9a';
+1 -1
View File
@@ -185,7 +185,7 @@ const cleanLicense = (value: string) => collapse(value).replace(/\s+license$/i,
const cleanLanguage = (value: string) => {
const text = collapse(value).replace(/\s+\d+(?:\.\d+)?%$/, "").trim();
if (!text || text.length > 40 || /%$/.test(text) || /^languages?$/i.test(text)) return "";
if (!text || text.length > 40 || text.endsWith("%") || /^languages?$/i.test(text)) return "";
return text;
};
+1 -1
View File
@@ -65,7 +65,7 @@ export const platformTarget = (pageUrl: string): PlatformTarget | null => {
return id ? { platform: "xiaoyuzhou", id, url: `https://www.xiaoyuzhoufm.com/episode/${id}` } : null;
}
if (url.hostname === "www.zhipin.com" || url.hostname === "zhipin.com") {
const id = /^\/job_detail\/([a-zA-Z0-9_~\-]+)\.html$/.exec(url.pathname)?.[1];
const id = /^\/job_detail\/([a-zA-Z0-9_~-]+)\.html$/.exec(url.pathname)?.[1];
return id ? { platform: "boss", id, url: `https://www.zhipin.com/job_detail/${id}.html` } : null;
}
if (url.hostname === "xueqiu.com" || url.hostname === "www.xueqiu.com") {
+1 -1
View File
@@ -1,7 +1,7 @@
/** Plain-text fallback when the clicked selection no longer has matching DOM. */
export const selectionTextMarkdown = (value: string) => value
// Entities keep Markdown, HTML and automatic links from reinterpreting literal text.
.replace(/[!-/:-@\[-`{-~]/g, (character) => `&#${character.charCodeAt(0)};`)
.replace(/[!-/:-@[-`{-~]/g, (character) => `&#${character.charCodeAt(0)};`)
.replace(/^[ \t]+/gm, (indent) => indent.replace(/ /g, "&#32;").replace(/\t/g, "&#9;"));
const escapeMarkdownLabel = (value: string) =>
+1 -1
View File
@@ -455,7 +455,7 @@ export const readLinkedin = (doc: Document, target: PlatformTarget): PlatformRea
link.closest(LINKEDIN_CARD_SELECTOR) === card && platformText(link));
const person = actorLinks.find(link => {
const url = safePlatformUrl(link.getAttribute('href') || '', target.url);
return url && /(^|\.)linkedin\.com$/.test(new URL(url).hostname) && /^\/in\//.test(new URL(url).pathname);
return url && /(^|\.)linkedin\.com$/.test(new URL(url).hostname) && new URL(url).pathname.startsWith("/in/");
});
const author = text(record(schema?.author)?.name) || platformText(own('.update-components-actor__title, [data-tracking-control-name="public_post_feed-actor-name"]'))
|| platformText(person?.querySelector('p') || person) || actorLinks.map(platformText).find(Boolean);
+1 -1
View File
@@ -71,7 +71,7 @@ export const readXueqiu = (doc: Document, target: PlatformTarget, sourceUrl: str
const title = (titleNode ? platformText(platformReadableCopy(titleNode)) : "") || platformText(clean).slice(0, 120);
if (!markdown || !title) return { ok: false, reason: "unreadable" };
const cleanTime = timeLink ? platformReadableCopy(timeLink) : null;
const published = cleanTime && /^发布于/.test(platformText(cleanTime)) ? publishedTime(cleanTime.querySelector("time")) : undefined;
const published = cleanTime && platformText(cleanTime).startsWith("发布于") ? publishedTime(cleanTime.querySelector("time")) : undefined;
return { ok: true, clip: { ...target, title, author: author ? platformText(platformReadableCopy(author)) || undefined : undefined, publishedAt: published,
markdown, tags: ["web-clip", "xueqiu", "投资", ...xueqiuStocks(clean, target.url)], completeness: "complete" } };
};
@@ -1718,9 +1718,9 @@ function LocalTiptapEditorImpl(props: LocalTiptapEditorProps) {
disabled={(action.id === "undo" && ((historyState ?? 0) & 1) === 0)
|| (action.id === "redo" && ((historyState ?? 0) & 2) === 0)
|| (action.id === "increaseListIndent"
&& !Boolean(editor?.can().chain().focus().sinkListItem(activeListItemType).run()))
&& !editor?.can().chain().focus().sinkListItem(activeListItemType).run())
|| (action.id === "decreaseListIndent"
&& !Boolean(editor?.can().chain().focus().liftListItem(activeListItemType).run()))}
&& !editor?.can().chain().focus().liftListItem(activeListItemType).run())}
icon={toolbarIcons[action.id]}
label={getMobileEditorToolbarActionLabel(action.id, props.locale)}
onRun={toolbarHandlers[action.id]}
@@ -112,6 +112,7 @@ export const useMobileAutomaticSync = ({
requestedRef.current = false;
requestedForceRef.current = false;
void runRef.current(nextForce);
// oxlint-disable-next-line no-unsafe-finally -- the catch above handles every error, so nothing is overridden; the finally block is restructured separately
return;
}
+2 -1
View File
@@ -67,7 +67,7 @@ const normalizeAttachmentFilename = (label: string, resourceId: string) => {
const withoutPrefix = label.replace(/^\s*(?:附件[::]|Attachment:)\s*/i, "").trim();
const colonParts = withoutPrefix.split(/[::]\s*/).filter(Boolean);
const candidate = (colonParts.at(-1) || withoutPrefix)
.replace(/^[\s📎📄📦📊🗃️🗂️]+/u, "")
.replace(/^[\uFE0F\s📎📄📦📊🗃🗂]+/u, "")
.trim();
return candidate || resourceId;
};
@@ -225,6 +225,7 @@ export const getMobileAttachmentUpdatePayload = (contentJson: TiptapDoc) => ({
export const getMobileResourceUpdatePayload = getMobileAttachmentUpdatePayload;
const safeCacheFilename = (filename: string) =>
// oxlint-disable-next-line no-control-regex -- strips control characters from cached filenames
filename.trim().replace(/[\\/]/g, "-").replace(/[\u0000-\u001f\u007f]/g, "").slice(0, 160) || "attachment";
const EXT_BY_MIME: Record<string, string> = {
+1 -1
View File
@@ -12,7 +12,7 @@ export const Meteors = ({ number = 20, className }: MeteorsProps) => {
);
useEffect(() => {
const styles = [...new Array(number)].map(() => ({
const styles = Array.from({ length: number }, () => ({
top: 0,
left: Math.floor(Math.random() * (400 - -400) + -400) + "px",
animationDelay: Math.random() * (0.8 - 0.2) + 0.2 + "s",
+1
View File
@@ -1,2 +1,3 @@
// oxlint-disable-next-line typescript/triple-slash-reference -- Astro loads its generated types this way
/// <reference path="../.astro/types.d.ts" />
/// <reference types="astro/client" />
@@ -875,7 +875,7 @@ const bindDiagramScrollerFit = (graph: Graph) => {
gridWidth as number | undefined,
gridHeight,
padding as number | undefined,
applyDiagramScrollerFitOptions({ ...(options ?? {}) }, bounds),
applyDiagramScrollerFitOptions({ ...options }, bounds),
);
}) as typeof graph.fitToContent & { edgeeverBound?: boolean };
bound.edgeeverBound = true;
@@ -1240,7 +1240,7 @@ const nodeMetadata = (
text: diagramNodePresentation(node, kind).text,
lineHeight: mindStyle?.visual.label.lineHeight ?? flowchartStyle?.label.lineHeight ?? architectureStyle?.label.lineHeight ?? 18,
},
...(architectureStyle?.attrs ?? {}),
...architectureStyle?.attrs,
},
...(hasPorts ? { ports: {
groups: {
+1 -1
View File
@@ -1138,7 +1138,7 @@ export default function PosterEditorPane({
const sourceSession = requiresLocalEditSession(source)
? createLocalEditSession(source)
: (await api.createMemoEditSession(source.id)).editSession;
const markdown = `${source.contentMarkdown}\n\n![${title.replace(/[\[\]\\\n]/g, " ")}](${resource.url})`;
const markdown = `${source.contentMarkdown}\n\n![${title.replace(/[[\]\\\n]/g, " ")}](${resource.url})`;
const result = await repository.updateMemo(source, {
expectedRevision: source.revision,
expectedContentHash: source.contentHash,
+1
View File
@@ -173,6 +173,7 @@ export const parseCustomEditorThemeFile = (source: string): PortableCustomEditor
export const customEditorThemeFileName = (name: string) => {
const safeName = name
.trim()
// oxlint-disable-next-line no-control-regex -- replaces control characters in theme filenames
.replace(/[\u0000-\u001f<>:"/\\|?*]+/g, "-")
.replace(/\s+/g, " ")
.replace(/[. ]+$/g, "")
+3 -7
View File
@@ -161,13 +161,9 @@ export const createDesktopRepository = (): EdgeEverRepository => ({
return local;
}
if (typeof navigator !== "undefined" && !navigator.onLine) return local;
try {
const remote = await api.listTemplates();
await Promise.all(remote.templates.map((template) => request("template.cache", { template })));
return remote;
} catch (error) {
throw error;
}
const remote = await api.listTemplates();
await Promise.all(remote.templates.map((template) => request("template.cache", { template })));
return remote;
},
createTemplate: (input) => request("template.create", input).then((result) => {
window.dispatchEvent(new CustomEvent("edgeever:sync-queue-changed"));
+1
View File
@@ -34,6 +34,7 @@ export class MarkdownExportMemoryLimitError extends Error {
export const sanitizeExportPathSegment = (value: string, fallback: string) => {
const sanitized = value
// oxlint-disable-next-line no-control-regex -- replaces control characters in export paths
.replace(/[\u0000-\u001f<>:"/\\|?*]/g, "-")
.replace(/\s+/g, " ")
.replace(/[. ]+$/g, "")
+1 -1
View File
@@ -1,6 +1,6 @@
const escapeLabel = (value: string) => value
.replaceAll("\\", "\\\\")
.replace(/[\[\]\r\n]/g, (character) => character === "\r" || character === "\n" ? " " : `\\${character}`);
.replace(/[[\]\r\n]/g, (character) => character === "\r" || character === "\n" ? " " : `\\${character}`);
export const resourceToMarkdown = (resource: { kind: "image" | "attachment"; url: string; filename: string | null }, fallbackName: string) => {
const label = escapeLabel(resource.filename || fallbackName || "file");
+1
View File
@@ -66,6 +66,7 @@ export const getHtmlImageEmbedNoticeKind = (
export const buildHtmlFilename = (title: string, fallback: string) => {
const sanitized = title
// oxlint-disable-next-line no-control-regex -- replaces control characters in export filenames
.replace(/[\u0000-\u001f<>:"/\\|?*]/g, "-")
.replace(/\s+/g, " ")
.replace(/[. ]+$/g, "")
+1
View File
@@ -14,6 +14,7 @@ const WINDOWS_RESERVED_NAME = /^(con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/i;
export const buildMarkdownFilename = (title: string, fallback: string) => {
const sanitized = title
// oxlint-disable-next-line no-control-regex -- replaces control characters in export filenames
.replace(/[\u0000-\u001f<>:"/\\|?*]/g, "-")
.replace(/\s+/g, " ")
.replace(/[. ]+$/g, "")
@@ -189,7 +189,7 @@ describe("EdgeEverPluginHost", () => {
set: async (pluginId, key, value) => secrets.set(`${pluginId}:${key}`, value),
remove: async (pluginId, key) => secrets.delete(`${pluginId}:${key}`),
clearNamespace: async (pluginId) => {
for (const key of [...secrets.keys()]) if (key.startsWith(`${pluginId}:`)) secrets.delete(key);
for (const key of Array.from(secrets.keys())) if (key.startsWith(`${pluginId}:`)) secrets.delete(key);
},
};
const packageStorage = {
@@ -289,7 +289,7 @@ describe("EdgeEverPluginHost", () => {
put: async (value) => packages.set(`${value.pluginId}:${value.version}`, value),
remove: async (pluginId, version) => {
if (version) packages.delete(`${pluginId}:${version}`);
else for (const key of [...packages.keys()]) if (key.startsWith(`${pluginId}:`)) packages.delete(key);
else for (const key of Array.from(packages.keys())) if (key.startsWith(`${pluginId}:`)) packages.delete(key);
},
};
const secretStorage = {
@@ -331,7 +331,7 @@ describe("EdgeEverPluginHost", () => {
put: async (value) => packages.set(`${value.pluginId}:${value.version}`, value),
remove: async (id, version) => {
if (version) packages.delete(`${id}:${version}`);
else for (const key of [...packages.keys()]) if (key.startsWith(`${id}:`)) packages.delete(key);
else for (const key of Array.from(packages.keys())) if (key.startsWith(`${id}:`)) packages.delete(key);
},
};
const host = new EdgeEverPluginHost({ repository, scope: "test", packageStorage });
+8 -8
View File
@@ -1047,24 +1047,24 @@ export class EdgeEverPluginHost {
try { dispose(); } catch { /* A broken disposer must not strand the plugin. */ }
}
try { await active.plugin.deactivate?.(); } catch { /* Cleanup must continue after a broken deactivate hook. */ }
for (const key of [...this.commands.keys()]) {
for (const key of Array.from(this.commands.keys())) {
if (key.startsWith(`${pluginId}:`)) this.commands.delete(key);
}
for (const key of [...this.panels.keys()]) {
for (const key of Array.from(this.panels.keys())) {
if (key.startsWith(`${pluginId}:`)) this.panels.delete(key);
}
for (const key of [...this.embeds.keys()]) {
for (const key of Array.from(this.embeds.keys())) {
if (key.startsWith(`${pluginId}:`)) this.embeds.delete(key);
}
for (const [key, disposers] of [...this.mountedPanels]) {
for (const [key, disposers] of Array.from(this.mountedPanels)) {
if (!key.startsWith(`${pluginId}:`)) continue;
for (const dispose of [...disposers]) {
for (const dispose of Array.from(disposers)) {
try { dispose(); } catch { /* Panel cleanup must not strand the plugin. */ }
}
}
for (const [key, embedDisposers] of [...this.mountedEmbeds]) {
for (const [key, embedDisposers] of Array.from(this.mountedEmbeds)) {
if (!key.startsWith(`${pluginId}:`)) continue;
for (const dispose of [...embedDisposers]) {
for (const dispose of Array.from(embedDisposers)) {
try { dispose(); } catch { /* Embed cleanup must not strand the plugin. */ }
}
}
@@ -1618,7 +1618,7 @@ export class EdgeEverPluginHost {
this.refreshSnapshot();
const dispose = () => {
this.embeds.delete(key);
for (const mountedDispose of [...(this.mountedEmbeds.get(key) ?? [])]) mountedDispose();
for (const mountedDispose of Array.from(this.mountedEmbeds.get(key) ?? [])) mountedDispose();
this.refreshSnapshot();
};
disposers.push(dispose);
+1 -1
View File
@@ -58,7 +58,7 @@ const MARKDOWN = `
- **行内公式**:例如质能方程 $E = mc^2$。
- **多行独立公式块**:
$$
\oint_{\\partial \\Omega} \\mathbf{E} \\cdot d\\mathbf{S} = \\frac{1}{\\varepsilon_0} \\iiint_{\\Omega} \\rho \\, dV
\\oint_{\\partial \\Omega} \\mathbf{E} \\cdot d\\mathbf{S} = \\frac{1}{\\varepsilon_0} \\iiint_{\\Omega} \\rho \\, dV
$$
- [x] 已完成的待办
+47 -6
View File
@@ -84,6 +84,7 @@
"fake-indexeddb": "^6.2.5",
"google-auth-library": "^11.0.2",
"linkedom": "0.18.13",
"oxlint": "1.88.0",
"tailwindcss": "^4.3.2",
"tw-animate-css": "^1.4.0",
"typescript": "6.0.3",
@@ -101,7 +102,7 @@
},
"apps/desktop": {
"name": "@edgeever/desktop",
"version": "1.112.0",
"version": "1.120.0",
"dependencies": {
"@agentclientprotocol/sdk": "^1.5.1",
"@edgeever/public-network": "workspace:*",
@@ -120,17 +121,17 @@
"name": "@edgeever/extension",
"version": "0.1.17",
"dependencies": {
"@joplin/turndown-plugin-gfm": "1.0.68",
"@mixmark-io/domino": "2.2.0",
"@mozilla/readability": "^0.6.0",
"marked": "^18.0.5",
"mdast-util-from-markdown": "2.0.3",
"mdast-util-gfm": "3.1.0",
"micromark-extension-gfm": "3.0.0",
"react": "~19.2.7",
"react-dom": "~19.2.7",
"sonner": "2.0.8",
"turndown": "^7.2.4",
"@joplin/turndown-plugin-gfm": "1.0.68",
"mdast-util-from-markdown": "2.0.3",
"mdast-util-gfm": "3.1.0",
"micromark-extension-gfm": "3.0.0",
},
"devDependencies": {
"web-ext": "^10.7.0",
@@ -331,9 +332,9 @@
},
"patchedDependencies": {
"expo-sharing@57.0.22": "patches/expo-sharing@57.0.22.patch",
"@joplin/turndown-plugin-gfm@1.0.68": "patches/@joplin%2Fturndown-plugin-gfm@1.0.68.patch",
"mermaid@11.16.1": "patches/mermaid@11.16.1.patch",
"npm@11.21.0": "patches/npm@11.21.0.patch",
"@joplin/turndown-plugin-gfm@1.0.68": "patches/@joplin%2Fturndown-plugin-gfm@1.0.68.patch",
},
"overrides": {
"dompurify": "3.4.16",
@@ -1174,6 +1175,44 @@
"@oxc-project/types": ["@oxc-project/types@0.153.0", "", {}, "sha512-jSNrRIyvPVLL5E1z/tS5zLAprosGz/FuI1PWwEOz59j3E4TTTzSpqKb/RDGqJhhW0QHYISGXxq582wGx6T11Tw=="],
"@oxlint/binding-android-arm-eabi": ["@oxlint/binding-android-arm-eabi@1.88.0", "", { "os": "android", "cpu": "arm" }, "sha512-k6Fgkf86ubfeH4GvqYsRolfuVAp0pfYVZvifSlXNHPdMaWUOfxUrmLEeDD7hF6U2oJuhsVxhSWq86/xCJ+9MmA=="],
"@oxlint/binding-android-arm64": ["@oxlint/binding-android-arm64@1.88.0", "", { "os": "android", "cpu": "arm64" }, "sha512-AsIyhl9o1ejwtac7NMbhKpMdSml6NWR0ljKjZERxC8OHYDAW8o/Om9n1QHVLi4VCEUo42Zte2/Op9kyoGpJZMg=="],
"@oxlint/binding-darwin-arm64": ["@oxlint/binding-darwin-arm64@1.88.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-qgBjB7eEKcCB4RqjXGuoSZq74g0wD09fWFJ+HWcCJcKaga8TeUHtb+BY0SsxdleSLBgx4RZ7qmgo0EzqEq7JoQ=="],
"@oxlint/binding-darwin-x64": ["@oxlint/binding-darwin-x64@1.88.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-sCz3X5Mw/xdzEzn4fsCt1Iq8NKJnGNfhR+b9B5k+4DysYAHsmIhYoocnBPiQ9kvY3+pUun2A0Z8Vtun/Nv6YoQ=="],
"@oxlint/binding-freebsd-x64": ["@oxlint/binding-freebsd-x64@1.88.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-xTOdYOIqCufgIj0knvxu86FBfovP6QBiD7ngNva01gb8xPQCD5gXkE5APGczH35zStim1O3zqvFbLQmY85xXRg=="],
"@oxlint/binding-linux-arm-gnueabihf": ["@oxlint/binding-linux-arm-gnueabihf@1.88.0", "", { "os": "linux", "cpu": "arm" }, "sha512-BLadtw88NdYJBrAtC1dYzrPfSP2d5zgx6iuZycdD0YmZBJ2Nqbit9Z4PSaIb6gxK25dweArGMPbOmq/0ugwUPg=="],
"@oxlint/binding-linux-arm-musleabihf": ["@oxlint/binding-linux-arm-musleabihf@1.88.0", "", { "os": "linux", "cpu": "arm" }, "sha512-wILSczl/DaZsCcf7j7tvNbssp8hxEHIJxrv4j7+JjI3TAL1U/JTWbeUtoJJC/vo8+2hkTmHLYoKJjmiNbijdXw=="],
"@oxlint/binding-linux-arm64-gnu": ["@oxlint/binding-linux-arm64-gnu@1.88.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-jutT2Hy8JCuZuMwIFHo2HtyxfOfBJHyQxlBHBK1SL9W2f8n/WhjAP0FnDMz9d/mjhv9+QA0mx5AdQ9fDjsrFFg=="],
"@oxlint/binding-linux-arm64-musl": ["@oxlint/binding-linux-arm64-musl@1.88.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-8N8iGV9hh4Lkeuq7QsEkjoXNRRflqR1G2qyoGYXcA0k5qllrdqmfylKEXyHIVERxqNDyMO4StqDQ0uLxXcBQAg=="],
"@oxlint/binding-linux-ppc64-gnu": ["@oxlint/binding-linux-ppc64-gnu@1.88.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-5ydcQt3znNQ/rGEWYRvpS2cK9+T18P2VYgnyJ+eloEAwi9YUmsXy5VGrRqNWIK6qzWwHGnEE3ojVqqQNo+f0hA=="],
"@oxlint/binding-linux-riscv64-gnu": ["@oxlint/binding-linux-riscv64-gnu@1.88.0", "", { "os": "linux", "cpu": "none" }, "sha512-iGx0uvFT54b/5DPJdEBCxYIrVB3v0uTy+JhRh3CyEp8/22BXDkG8rffkFwREdLsdj9mOrPbJYVOZw8ipcBuzjw=="],
"@oxlint/binding-linux-riscv64-musl": ["@oxlint/binding-linux-riscv64-musl@1.88.0", "", { "os": "linux", "cpu": "none" }, "sha512-Wt47mKMWelVjRjuR7BCFxXkB0m79K1sTEbqMHRWciKUgo5iv7sWsF4RXSOyFYHD9zerNrkRZblnN273rhwOA1Q=="],
"@oxlint/binding-linux-s390x-gnu": ["@oxlint/binding-linux-s390x-gnu@1.88.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-g3L12KCS4+1gtUt3RbJ4F+Yxx4LNmmZLEPfhd8rAHEJjJk2lAgNfnjGoP/phJyhG5WS6VbNYbJtadJelNfvTMA=="],
"@oxlint/binding-linux-x64-gnu": ["@oxlint/binding-linux-x64-gnu@1.88.0", "", { "os": "linux", "cpu": "x64" }, "sha512-og/XHVDOD9ZJgRiZkT9DgzQd4sr+JZW7e4l17Q/jVMZiIbeEd4uIu9iAoUbTF1gIvVZoRc4QDOxyVUvQGp4WHg=="],
"@oxlint/binding-linux-x64-musl": ["@oxlint/binding-linux-x64-musl@1.88.0", "", { "os": "linux", "cpu": "x64" }, "sha512-EmhBsp+xuKb6YbZNEMLBo80Xw71AZOIm18do8ni/GLX/Lw1XzNhVYpbxO1KasYtoAvrGtCOdCPULRlIE0bPbTQ=="],
"@oxlint/binding-openharmony-arm64": ["@oxlint/binding-openharmony-arm64@1.88.0", "", { "os": "none", "cpu": "arm64" }, "sha512-DBUcx/KwYdg1DAz+G01P/GFv512J04EsZadbHptx2FTpWT/MWRg21ZToNHt9pnsfQRBQ3et/g+vqzbvrdAVbuA=="],
"@oxlint/binding-win32-arm64-msvc": ["@oxlint/binding-win32-arm64-msvc@1.88.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-xeVd0n4XG5HvynxfYvXi6uB+JvZbVZataowPS8cYpMbYg9mwE4sSyElFQd4LVJFifq8bRLDSR18XgEildmcXAg=="],
"@oxlint/binding-win32-ia32-msvc": ["@oxlint/binding-win32-ia32-msvc@1.88.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-MA6iOw//amleaz2mVMsZrA7NHlEexjTrhIxSMFCXQi6IUuQO+3Od00GZhr2iCYw6RFzMW36jkKK/WHBXMsoQAw=="],
"@oxlint/binding-win32-x64-msvc": ["@oxlint/binding-win32-x64-msvc@1.88.0", "", { "os": "win32", "cpu": "x64" }, "sha512-3Sz6rVWe8Dy+ufsXurFN1GX2dX43F/lQMSLelK9gFjiba7nHvLlqlhR5cHlWVnCpSqUY1hN9pyCX9XToe6ve6g=="],
"@peculiar/asn1-schema": ["@peculiar/asn1-schema@2.8.0", "", { "dependencies": { "@peculiar/utils": "^2.0.2", "asn1js": "^3.0.10", "tslib": "^2.8.1" } }, "sha512-7YT0U/ze0tF2QOBbE15gKZwy5tvgGyLRiRHLzhlbOpf7BT032oBSd0haZqXn5W6l26WLlu3dyxzjM+2638/z2Q=="],
"@peculiar/json-schema": ["@peculiar/json-schema@1.1.12", "", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-coUfuoMeIB7B8/NMekxaDzLhaYmp0HZNPEjYRm9goRou8UZIC3z21s0sL9AWoCw4EG876QyO3kYrc61WNF9B/w=="],
@@ -3452,6 +3491,8 @@
"own-keys": ["own-keys@1.0.1", "", { "dependencies": { "get-intrinsic": "^1.2.6", "object-keys": "^1.1.1", "safe-push-apply": "^1.0.0" } }, "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg=="],
"oxlint": ["oxlint@1.88.0", "", { "optionalDependencies": { "@oxlint/binding-android-arm-eabi": "1.88.0", "@oxlint/binding-android-arm64": "1.88.0", "@oxlint/binding-darwin-arm64": "1.88.0", "@oxlint/binding-darwin-x64": "1.88.0", "@oxlint/binding-freebsd-x64": "1.88.0", "@oxlint/binding-linux-arm-gnueabihf": "1.88.0", "@oxlint/binding-linux-arm-musleabihf": "1.88.0", "@oxlint/binding-linux-arm64-gnu": "1.88.0", "@oxlint/binding-linux-arm64-musl": "1.88.0", "@oxlint/binding-linux-ppc64-gnu": "1.88.0", "@oxlint/binding-linux-riscv64-gnu": "1.88.0", "@oxlint/binding-linux-riscv64-musl": "1.88.0", "@oxlint/binding-linux-s390x-gnu": "1.88.0", "@oxlint/binding-linux-x64-gnu": "1.88.0", "@oxlint/binding-linux-x64-musl": "1.88.0", "@oxlint/binding-openharmony-arm64": "1.88.0", "@oxlint/binding-win32-arm64-msvc": "1.88.0", "@oxlint/binding-win32-ia32-msvc": "1.88.0", "@oxlint/binding-win32-x64-msvc": "1.88.0" }, "peerDependencies": { "oxlint-tsgolint": ">=7.0.2003", "vite-plus": "*" }, "optionalPeers": ["oxlint-tsgolint", "vite-plus"], "bin": { "oxlint": "bin/oxlint" } }, "sha512-uIcFgw8BmqvFRGzH5+r7d2zJMEexwNSrEmM6WQNnGh3VkO0XYTEFoyxyNE6tjKMUib9Cue4XJA2DoWruO5D6aw=="],
"p-cancelable": ["p-cancelable@2.1.1", "", {}, "sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg=="],
"p-limit": ["p-limit@6.2.0", "", { "dependencies": { "yocto-queue": "^1.1.1" } }, "sha512-kuUqqHNUqoIWp/c467RI4X6mmyuojY5jGutNU0wVTmEOOfcuwLqyMVoAi9MKi2Ak+5i9+nhmrK4ufZE8069kHA=="],
+2
View File
@@ -77,6 +77,7 @@
"build:site": "bun --cwd apps/site build",
"preview:web": "vite preview --config apps/web/vite.config.ts --host 127.0.0.1 --port 4173",
"preview:site": "bun --cwd apps/site preview",
"lint": "oxlint --deny-warnings apps packages",
"typecheck": "tsc -p tsconfig.json --noEmit",
"typecheck:mobile": "bun run --cwd apps/mobile typecheck",
"typecheck:ios-editor": "bun run --cwd apps/ios/EditorSource typecheck",
@@ -199,6 +200,7 @@
"fake-indexeddb": "^6.2.5",
"google-auth-library": "^11.0.2",
"linkedom": "0.18.13",
"oxlint": "1.88.0",
"tailwindcss": "^4.3.2",
"tw-animate-css": "^1.4.0",
"typescript": "6.0.3",
+6 -4
View File
@@ -91,6 +91,7 @@ const escapeMarkdownLabel = (value: string) =>
/** http(s) URLs that are not already stored EdgeEver resources. */
const isRemoteHttpUrl = (value: string) => {
// oxlint-disable-next-line no-control-regex -- rejects URLs containing control characters or whitespace
if (!value || /[\u0000-\u0020"'<>`]/.test(value)) return false;
if (getResourceIdFromUrl(value)) return false;
try {
@@ -102,6 +103,7 @@ const isRemoteHttpUrl = (value: string) => {
};
const isDetailsImageSrc = (value: string) => {
// oxlint-disable-next-line no-control-regex -- rejects image sources containing control characters or whitespace
if (!value || /[\u0000-\u0020"'<>`]/.test(value)) return false;
if (value.startsWith("edgeever-resource://") || value.startsWith("edgeever-staged://")) return true;
if (value.startsWith("/") && !value.startsWith("//")) return true;
@@ -135,7 +137,7 @@ const remoteVideoFromSrc = (src: string): RemoteVideo | null => {
} catch {
segment = "";
}
if (!segment || segment.length > 120 || /[\\/\[\]()<>]/.test(segment)) segment = "";
if (!segment || segment.length > 120 || /[\\/[\]()<>]/.test(segment)) segment = "";
const filename = segment || "video";
return {
src: url,
@@ -320,7 +322,7 @@ export const wrapDetailsContentHtml = (html: string): string => {
const document = new DOMParser().parseFromString(html, "text/html");
const blocks = [...document.querySelectorAll("details")].reverse();
for (const details of blocks) {
for (const video of [...details.querySelectorAll("video")]) {
for (const video of Array.from(details.querySelectorAll("video"))) {
if (video.closest("[data-type='edgeever-file-attachment'], [data-edgeever-video-player]")) continue;
const parsed = remoteVideoFromSrc(video.getAttribute("src") || "");
if (!parsed) {
@@ -332,7 +334,7 @@ export const wrapDetailsContentHtml = (html: string): string => {
if (details.querySelector(':scope > [data-type="detailsContent"]')) continue;
const wrapper = document.createElement("div");
wrapper.setAttribute("data-type", "detailsContent");
for (const child of [...details.childNodes]) {
for (const child of Array.from(details.childNodes)) {
if (child.nodeName === "SUMMARY") continue;
wrapper.appendChild(child);
}
@@ -367,7 +369,7 @@ export const flattenDetailsForLinearHtml = (root: HTMLElement) => {
heading.appendChild(strong);
replacement.appendChild(heading);
}
for (const child of [...details.childNodes]) {
for (const child of Array.from(details.childNodes)) {
if (child === summary) continue;
if (child instanceof HTMLElement && child.getAttribute("data-type") === "detailsContent") {
while (child.firstChild) {
@@ -1,6 +1,7 @@
import type { DiagramNodeShape } from "./diagram";
export const visualTextUnits = (label: string) => Array.from(label).reduce(
// oxlint-disable-next-line no-control-regex -- tests for characters outside Latin-1
(total, character) => total + (/[^\u0000-\u00ff]/.test(character) ? 1 : 0.55),
0,
);
+1 -1
View File
@@ -394,7 +394,7 @@ export const applyDiagramOperations = async (
}
if (current.kind === "architecture" && affectedBoundaryIds.size > 0) {
const nodeById = new Map(next.nodes.map((node) => [node.id, node]));
for (const boundaryId of [...affectedBoundaryIds]) {
for (const boundaryId of Array.from(affectedBoundaryIds)) {
let parentId = nodeById.get(boundaryId)?.parentId;
while (parentId) {
affectedBoundaryIds.add(parentId);
+1 -1
View File
@@ -145,7 +145,7 @@ export const diagramDocumentToX6Cells = (
} : {}),
},
...(mindVisual ? { underline: mindVisual.underline } : {}),
...(architectureVisual?.attrs ?? {}),
...architectureVisual?.attrs,
},
};
});
+1
View File
@@ -510,6 +510,7 @@ export const resolveTheme = (background?: NoteImageBackground, theme?: NoteImage
export const buildImageExportBasename = (title: string, fallback: string) => {
const sanitized = title
// oxlint-disable-next-line no-control-regex -- replaces control characters in export filenames
.replace(/[\u0000-\u001f<>:"/\\|?*]/g, "-")
.replace(/\s+/g, " ")
.replace(/[. ]+$/g, "")
+1 -1
View File
@@ -69,7 +69,7 @@ export const posterPatchSchema = z.object({
radius: z.number().min(0).max(4096).optional(),
resourceId: z.string().min(1).max(160).regex(/^[\w-]+$/).optional(),
crop: z.object({ x: z.number().min(0).max(1), y: z.number().min(0).max(1), width: z.number().min(0.01).max(1), height: z.number().min(0.01).max(1) }).strict().refine(crop => crop.x + crop.width <= 1 && crop.y + crop.height <= 1).nullable().optional(),
path: z.string().min(1).max(100000).regex(/^[MmLlHhVvCcSsQqTtAaZz\d\s,.eE+\-]+$/).optional(),
path: z.string().min(1).max(100000).regex(/^[MmLlHhVvCcSsQqTtAaZz\d\s,.eE+-]+$/).optional(),
text: z.string().max(8000).optional(),
fill: z.string().regex(/^#[0-9a-f]{6}$/i).optional(),
fontSize: z.number().finite().min(4).max(8192).optional(),
+2 -2
View File
@@ -129,7 +129,7 @@ const path = z.object({
.string()
.min(1)
.max(100_000)
.regex(/^[MmLlHhVvCcSsQqTtAaZz\d\s,.eE+\-]+$/),
.regex(/^[MmLlHhVvCcSsQqTtAaZz\d\s,.eE+-]+$/),
});
export const PosterElementSchema = z.union([text, shape, image, path]);
export const PosterDocumentSchema = z
@@ -294,7 +294,7 @@ export const posterFallbackMarkdown = (document: PosterDocument) =>
: "",
...document.elements
.filter((element): element is PosterText => element.type === "text")
.map((element) => element.text.replace(/([\\`*_{}\[\]<>#!])/g, "\\$1")),
.map((element) => element.text.replace(/([\\`*_{}[\]<>#!])/g, "\\$1")),
document.sourceMemoId ? `[Source](#memo=${document.sourceMemoId})` : "",
...document.elements
.filter((element) => element.type === "image")
+1
View File
@@ -348,6 +348,7 @@ export const AiDefaultModelUpdateSchema = z.object({
});
export const AiAttachmentSchema = z.object({
// oxlint-disable-next-line no-control-regex -- rejects control characters in attachment filenames
filename: z.string().trim().min(1).max(255).regex(/^[^\u0000-\u001F\u007F]+$/),
mediaType: z.enum(AI_ATTACHMENT_MEDIA_TYPES),
base64Data: z.string().min(1).max(Math.ceil(MAX_AI_ATTACHMENT_BYTES / 3) * 4),