diff --git a/build_client.ts b/build_client.ts index e25bc697..f81ae3df 100644 --- a/build_client.ts +++ b/build_client.ts @@ -1,4 +1,4 @@ -import { cp, mkdir, readFile, writeFile } from "node:fs/promises"; +import { cp, mkdir, readdir, readFile, writeFile } from "node:fs/promises"; import { fileURLToPath } from "node:url"; import { dirname } from "node:path"; import * as sass from "sass"; @@ -28,7 +28,7 @@ export async function copyAssets(dist: string) { const scssContent = await readFile("client/styles/main.scss", "utf-8"); const result = sass.compileString(scssContent, { loadPaths: ["client/styles"], - style: "expanded", + style: "compressed", }); await writeFile(`${dist}/main.css`, result.css, "utf-8"); @@ -62,7 +62,10 @@ async function buildCopyBundleAssets() { sourcemap: "linked", minify: true, jsxFactory: "h", - // metafile: true, + metafile: true, + splitting: true, + format: "esm", + chunkNames: ".client/[name]-[hash]", jsx: "automatic", jsxFragment: "Fragment", jsxImportSource: "preact", @@ -73,16 +76,32 @@ async function buildCopyBundleAssets() { console.log("Bundle info", text); } - // Patch the service_worker {{CACHE_NAME}} + await copyAssets("client_bundle/client/.client"); + + // Scan .client/ directory to build the full precache file list + const clientDir = "client_bundle/client/.client"; + const allFiles = await readdir(clientDir); + const precacheFiles = [ + "/", // The index page + "/.client/manifest.json", // Dynamically generated by the server, but needed for PWA + ...allFiles + .filter((f) => + !f.endsWith(".map") && f !== "auth.html" && f !== "index.html" && + f !== "LICENSE.md" + ) + .map((f) => `/.client/${f}`), + ]; + const precacheFilesStr = precacheFiles.join(","); + + // Patch the service_worker {{CACHE_NAME}} and {{PRECACHE_FILES}} let swCode = await readFile( "client_bundle/client/service_worker.js", "utf-8", ); swCode = swCode.replaceAll("{{CACHE_NAME}}", `cache-${Date.now()}`); + swCode = swCode.replaceAll("{{PRECACHE_FILES}}", precacheFilesStr); await writeFile("client_bundle/client/service_worker.js", swCode, "utf-8"); - await copyAssets("client_bundle/client/.client"); - console.log("Built!"); } diff --git a/client/client.ts b/client/client.ts index 08a8db90..898cddee 100644 --- a/client/client.ts +++ b/client/client.ts @@ -96,6 +96,7 @@ export class Client { // CodeMirror editor editorView!: EditorView; commandKeyHandlerCompartment?: Compartment; + vimCompartment?: Compartment; indentUnitCompartment?: Compartment; undoHistoryCompartment?: Compartment; diff --git a/client/codemirror/editor_paste.ts b/client/codemirror/editor_paste.ts index a430033e..48b50ba1 100644 --- a/client/codemirror/editor_paste.ts +++ b/client/codemirror/editor_paste.ts @@ -2,12 +2,6 @@ import { syntaxTree } from "@codemirror/language"; import { EditorView, ViewPlugin, type ViewUpdate } from "@codemirror/view"; import type { Client } from "../client.ts"; -// We use turndown to convert HTML to Markdown -import TurndownService from "turndown"; - -// With tables and task notation as well -// @ts-expect-error - No type definitions available for this package -import { tables, taskListItems } from "@joplin/turndown-plugin-gfm"; import { lezerToParseTree } from "../markdown_parser/parse_tree.ts"; import { addParentPointers, @@ -21,17 +15,30 @@ import { localDateString } from "@silverbulletmd/silverbullet/lib/dates"; import type { UploadFile } from "@silverbulletmd/silverbullet/type/client"; import { isValidName, isValidPath } from "@silverbulletmd/silverbullet/lib/ref"; -const turndownService = new TurndownService({ - hr: "---", - codeBlockStyle: "fenced", - headingStyle: "atx", - emDelimiter: "*", - bulletListMarker: "*", // Duh! - strongDelimiter: "**", - linkStyle: "inlined", -}); -turndownService.use(taskListItems); -turndownService.use(tables); +// Dynamically load the turndown service (only used for clipboard pasete) +let turndownService: any = null; +async function getTurndownService() { + if (!turndownService) { + const [{ default: TurndownService }, { tables, taskListItems }] = + await Promise.all([ + import("turndown"), + // @ts-expect-error - No type definitions available for this package + import("@joplin/turndown-plugin-gfm"), + ]); + turndownService = new TurndownService({ + hr: "---", + codeBlockStyle: "fenced", + headingStyle: "atx", + emDelimiter: "*", + bulletListMarker: "*", // Duh! + strongDelimiter: "**", + linkStyle: "inlined", + }); + turndownService.use(taskListItems); + turndownService.use(tables); + } + return turndownService; +} function striptHtmlComments(s: string): string { return s.replace(//g, ""); @@ -137,7 +144,7 @@ export function documentExtension(editor: Client) { // Only do rich text paste if shift is NOT down if (richText && !shiftDown) { - // Are we in a fencede code block? + // Are we in a fenced code block? const editorText = editor.editorView.state.sliceDoc(); const tree = lezerToParseTree( editorText, @@ -161,23 +168,26 @@ export function documentExtension(editor: Client) { } } - const markdown = striptHtmlComments( - turndownService.turndown(richText), - ).trim(); + // Prevent default immediately, then do async turndown conversion + event.preventDefault(); const view = editor.editorView; const selection = view.state.selection.main; - view.dispatch({ - changes: [ - { - from: selection.from, - to: selection.to, - insert: markdown, + safeRun(async () => { + const td = await getTurndownService(); + const markdown = striptHtmlComments(td.turndown(richText)).trim(); + view.dispatch({ + changes: [ + { + from: selection.from, + to: selection.to, + insert: markdown, + }, + ], + selection: { + anchor: selection.from + markdown.length, }, - ], - selection: { - anchor: selection.from + markdown.length, - }, - scrollIntoView: true, + scrollIntoView: true, + }); }); return true; } diff --git a/client/codemirror/editor_state.ts b/client/codemirror/editor_state.ts index 5d28c9a3..e7b4346a 100644 --- a/client/codemirror/editor_state.ts +++ b/client/codemirror/editor_state.ts @@ -34,9 +34,9 @@ import { ViewPlugin, type ViewUpdate, } from "@codemirror/view"; -import { vim } from "@replit/codemirror-vim"; import { markdown } from "@codemirror/lang-markdown"; import type { Client } from "../client.ts"; +import { loadVim } from "../vim_loader.ts"; import { inlineContentPlugin } from "./inline_content.ts"; import { cleanModePlugins } from "./clean.ts"; import { lineWrapper } from "./line_wrapper.ts"; @@ -63,6 +63,7 @@ export function createEditorState( // Ugly: keep the commandKeyHandler compartment in the client, to be replaced // later once more commands are loaded client.commandKeyHandlerCompartment = new Compartment(); + client.vimCompartment = new Compartment(); const commandKeyBindings = client.commandKeyHandlerCompartment.of( createCommandKeyBindings(client), ); @@ -76,6 +77,13 @@ export function createEditorState( client.undoHistoryCompartment = new Compartment(); const undoHistory = client.undoHistoryCompartment.of([history()]); + const vimMode = client.ui.viewState.uiOptions.vimMode; + + // If vim mode is requested, load it async and reconfigure the compartment + if (vimMode) { + void enableVimMode(client); + } + return EditorState.create({ doc: text, extensions: [ @@ -93,15 +101,8 @@ export function createEditorState( // bindings wont trigger if they have the same keys. commandKeyBindings, - // Enable vim mode, or not - [ - ...(client.ui.viewState.uiOptions.vimMode - ? [ - vim({ status: true }), - EditorState.allowMultipleSelections.of(true), - ] - : []), - ], + // Vim mode compartment — starts empty, loaded async if needed + client.vimCompartment.of([]), [ ...(readOnly || client.ui.viewState.uiOptions.forcedROMode || @@ -425,6 +426,18 @@ export function createRegularKeyBindings(client: Client): Extension { } } +async function enableVimMode(client: Client) { + const { vim } = await loadVim(); + if (client.editorView && client.vimCompartment) { + client.editorView.dispatch({ + effects: client.vimCompartment.reconfigure([ + vim({ status: true }), + EditorState.allowMultipleSelections.of(true), + ]), + }); + } +} + /** * Checks if the current platform is Mac-like (Mac, iPhone, iPod, iPad). * @returns A boolean indicating if the platform is Mac-like. diff --git a/client/components/mini_editor.tsx b/client/components/mini_editor.tsx index 644cb6be..3c0f2092 100644 --- a/client/components/mini_editor.tsx +++ b/client/components/mini_editor.tsx @@ -8,7 +8,7 @@ import { ViewPlugin, type ViewUpdate, } from "@codemirror/view"; -import { getCM as vimGetCm, Vim, vim } from "@replit/codemirror-vim"; +import { getVimModule } from "../vim_loader.ts"; import { createCommandKeyBindings } from "../codemirror/editor_state.ts"; type MiniEditorEvents = { @@ -131,15 +131,16 @@ export function MiniEditor({ // When vim mode is active, we need for CM to have created the new state // and the subscribe to the vim mode's events // This needs to happen in the next tick, so we wait a tick with setTimeout - if (vimMode) { + const vimMod = getVimModule(); + if (vimMode && vimMod) { // Only applies to vim mode setTimeout(() => { - const cm = vimGetCm(editorViewRef.current!)!; + const cm = vimMod.getCM(editorViewRef.current!)!; cm.on("vim-mode-change", ({ mode }: { mode: string }) => { vimModeRef.current = mode; }); if (vimStartInInsertMode) { - Vim.handleKey(cm, "i", "+input"); + vimMod.Vim.handleKey(cm, "i", "+input"); } }); } @@ -150,8 +151,8 @@ export function MiniEditor({ // Insert command bindings before vim-mode to ensure they're available // in normal mode. See editor_state.ts for more details. createCommandKeyBindings(globalThis.client), - // Enable vim mode, or not - [...(vimMode ? [vim()] : [])], + // Enable vim mode, or not (uses already-loaded module if available) + [...(vimMode && vimMod ? [vimMod.vim()] : [])], [ ...(editable ? [] diff --git a/client/config.test.ts b/client/config.test.ts index 8aef3bdf..7b052e8b 100644 --- a/client/config.test.ts +++ b/client/config.test.ts @@ -1,137 +1,57 @@ import { expect, test } from "vitest"; import { Config } from "./config.ts"; -test("Config - basic functionality", () => { +test("Config - basic get/set/has/insert", () => { const config = new Config(); - // Test set and get config.set("testKey", "testValue"); expect(config.get(["testKey"], null)).toEqual("testValue"); - // Test insert config.insert("plugs", "plug1"); config.insert("plugs", "plug1"); expect(config.get("plugs", null)).toEqual(["plug1", "plug1"]); - // Test default value expect(config.get("nonExistentKey", "default")).toEqual("default"); - - // Test has expect(config.has("testKey")).toBeTruthy(); - expect(!config.has("nonExistentKey")).toBeTruthy(); + expect(config.has("nonExistentKey")).toBeFalsy(); }); -test("Config - object-based setting", () => { - const config = new Config(); +test("Config - object-based and dot-notation setting", () => { + const config = new Config({ simple: "value", nested: { key: "nestedValue" } }); - // Test setting multiple values at once - config.set({ - key1: "value1", - key2: "value2", - key3: 123, - }); + expect(config.get("simple", null)).toEqual("value"); + expect(config.get("nested.key", null)).toEqual("nestedValue"); + config.set({ key1: "value1", "key2.nested": 123 }); expect(config.get("key1", null)).toEqual("value1"); - expect(config.get("key2", null)).toEqual("value2"); - expect(config.get("key3", 0)).toEqual(123); -}); + expect(config.get("key2.nested", 0)).toEqual(123); -test("Config - paths", () => { - const config = new Config(); - - // Test setting with dot notation - config.set("user.name", "John"); config.set("user.profile.age", 30); - - // Test getting with dot notation - expect(config.get("user.name", null)).toEqual("John"); expect(config.get("user.profile.age", 0)).toEqual(30); + expect(config.has("user.profile.age")).toBeTruthy(); + expect(config.has("user.profile.nonExistent")).toBeFalsy(); config.set(["user", "name"], "Pete"); - config.set(["user", "profile", "age"], 20); expect(config.get(["user", "name"], null)).toEqual("Pete"); - expect(config.get(["user", "profile", "age"], 0)).toEqual(20); - - // Test has with dot notation - expect(config.has("user.name")).toBeTruthy(); - expect(config.has("user.profile")).toBeTruthy(); - expect(config.has("user.profile.age")).toBeTruthy(); - expect(!config.has("user.profile.nonExistent")).toBeTruthy(); - - // Test with path notation - expect(config.has(["user", "name"])).toBeTruthy(); - expect(config.has(["user", "profile", "age"])).toBeTruthy(); - - // Test getting the entire object - expect(config.get("user", {})).toEqual({ - name: "Pete", - profile: { - age: 20, - }, - }); + expect(config.get("user", {})).toEqual({ name: "Pete", profile: { age: 30 } }); }); test("Config - edge cases", () => { const config = new Config(); - // Test setting a value on a non-existent path + // Deep nesting from scratch config.set("a.b.c", "value"); expect(config.get("a.b.c", null)).toEqual("value"); - // Test overwriting a primitive with an object + // Overwriting a primitive with an object config.set("x", "primitive"); - expect(config.get("x", null)).toEqual("primitive"); - config.set("x.y", "nested"); expect(config.get("x.y", null)).toEqual("nested"); - - // Test setting a value on a path where part of the path is a primitive - config.set("p", "primitive"); - config.set("p.q.r", "nested"); - expect(config.get("p.q.r", null)).toEqual("nested"); - - // Test deep nesting - config.set("deep.nesting.test.value", 42); - expect(config.get("deep.nesting.test.value", 0)).toEqual(42); - - // Test with empty string key - config.set("", "empty"); - expect(config.get("", null)).toEqual("empty"); - - // Test with special characters in key - config.set("special!@#", "chars"); - expect(config.get("special!@#", null)).toEqual("chars"); -}); - -test("Config - object setting with dot notation in keys", () => { - const config = new Config(); - - // Test setting an object with dot notation in keys - config.set({ - "user.name": "John", - "user.profile.age": 30, - }); - - expect(config.get("user.name", null)).toEqual("John"); - expect(config.get("user.profile.age", 0)).toEqual(30); -}); - -test("Config - constructor with initial values", () => { - const config = new Config({ - simple: "value", - nested: { - key: "nestedValue", - }, - }); - - expect(config.get("simple", null)).toEqual("value"); - expect(config.get("nested.key", null)).toEqual("nestedValue"); }); test("Config - schema validation", () => { const config = new Config(); - // Define a schema for a key const userSchema = { type: "object", properties: { @@ -144,153 +64,77 @@ test("Config - schema validation", () => { config.define("user", userSchema); - // Valid data should work - config.set("user", { - name: "John", - age: 30, - email: "john@example.com", - }); - + // Valid data + config.set("user", { name: "John", age: 30, email: "john@example.com" }); config.set("user.name", "John"); - // Missing required field should throw - expect(() => { - config.set("user", { - name: "John", - }); - }).toThrow("Validation error for user"); + // Missing required field + expect(() => config.set("user", { name: "John" })) + .toThrow("Validation error for user"); - // Wrong type should throw - expect(() => { - config.set("user", { - name: "John", - age: "thirty", // Should be a number - }); - }).toThrow("Validation error for user"); + // Wrong type + expect(() => config.set("user", { name: "John", age: "thirty" })) + .toThrow("Validation error for user"); - // Wrong type should throw with path - expect(() => { - config.set(["user", "name"], 22); - }).toThrow("Validation error for user"); + // Wrong type via path + expect(() => config.set(["user", "name"], 22)) + .toThrow("Validation error for user"); - // Invalid format should throw - expect(() => { - config.set("user", { - name: "John", - age: 30, - email: "not-an-email", - }); - }).toThrow("Validation error for user"); + // Invalid format (email without @) + expect(() => config.set("user", { name: "John", age: 30, email: "not-an-email" })) + .toThrow("Validation error for user"); - // Value below minimum should throw - expect(() => { - config.set("user", { - name: "John", - age: -1, // Should be >= 0 - }); - }).toThrow("Validation error for user"); - - // Check nested keys - config.define("a.b.user", userSchema); - - console.log(config.schemas); - - config.set("a.b.user.name", "Hank"); - expect(() => { - config.set("a.b.user.name", 22); - }).toThrow("Validation error for a.b.user"); + // Value below minimum + expect(() => config.set("user", { name: "John", age: -1 })) + .toThrow("Validation error for user"); }); -test("Config - nested schema definitions with arrays", () => { +test("Config - nested schema definitions", () => { const config = new Config(); - // Define schema for nested path using array syntax - const settingsSchema = { + config.define("a.b.user", { type: "object", - properties: { - theme: { type: "string" }, - maxItems: { type: "number", minimum: 1 }, - }, - required: ["theme"], - }; - - config.define(["app", "ui", "settings"], settingsSchema); - - // Valid nested setting should work - config.set("app.ui.settings", { - theme: "dark", - maxItems: 10, + properties: { name: { type: "string" } }, + required: ["name"], }); - expect(config.get("app.ui.settings.theme", null)).toEqual("dark"); - expect(config.get("app.ui.settings.maxItems", 0)).toEqual(10); - - // Setting individual nested properties should work - config.set("app.ui.settings.theme", "light"); - expect(config.get("app.ui.settings.theme", null)).toEqual("light"); - - // Invalid type should throw - expect(() => { - config.set("app.ui.settings.theme", 123); - }).toThrow("Validation error for app.ui.settings"); - - // Invalid value should throw - expect(() => { - config.set("app.ui.settings.maxItems", -1); - }).toThrow("Validation error for app.ui.settings"); -}); - -test("Config - multiple nested schemas", () => { - const config = new Config(); - - // Define multiple schemas at different nesting levels - const userSchema = { + config.define("system.config", { type: "object", - properties: { - name: { type: "string" }, - age: { type: "number" }, - }, - required: ["name"], - }; - - const configSchema = { - type: "object", - properties: { - version: { type: "string" }, - enabled: { type: "boolean" }, - }, + properties: { version: { type: "string" }, enabled: { type: "boolean" } }, required: ["version"], - }; + }); - config.define("user", userSchema); - config.define("system.config", configSchema); - - // Both schemas should work independently - config.set("user.name", "Alice"); + config.set("a.b.user.name", "Hank"); config.set("system.config.version", "1.0.0"); config.set("system.config.enabled", true); - expect(config.get("user.name", null)).toEqual("Alice"); - expect(config.get("system.config.version", null)).toEqual("1.0.0"); - expect(config.get("system.config.enabled", false)).toEqual(true); - - // Validation should work for both - expect(() => { - config.set("user.name", 123); - }).toThrow("Validation error for user"); - - expect(() => { - config.set("system.config.enabled", "not-boolean"); - }).toThrow("Validation error for system.config"); + expect(() => config.set("a.b.user.name", 22)) + .toThrow("Validation error for a.b.user"); + expect(() => config.set("system.config.enabled", "not-boolean")) + .toThrow("Validation error for system.config"); }); -test("Config - invalid schema definition", () => { +test("Config - define() rejects invalid schemas", () => { const config = new Config(); - // Invalid schema should throw when defined - expect(() => { - config.define("test", { - type: "invalid-type", // Invalid type - }); - }).toThrow("Invalid schema for key test"); + expect(() => config.define("bad", { type: "invalid-type" })) + .toThrow(/^Invalid schema for key bad: /); + expect(() => config.define(["a", "b"], { type: "bogus" })) + .toThrow(/^Invalid schema for key a,b: /); +}); + +test("Config - custom format validation", () => { + const config = new Config(); + + // page-ref format + config.define("link", { + type: "object", + properties: { ref: { type: "string", format: "page-ref" } }, + }); + + config.set("link", { ref: "[[my page]]" }); + expect(() => config.set("link", { ref: "not a ref" })) + .toThrow("Validation error for link"); + expect(() => config.set("link", { ref: "[[no close" })) + .toThrow("Validation error for link"); }); diff --git a/client/config.ts b/client/config.ts index 62ac218c..2fae198d 100644 --- a/client/config.ts +++ b/client/config.ts @@ -1,4 +1,80 @@ -import { Ajv } from "ajv"; +import { type OutputUnit, Validator, format } from "@cfworker/json-schema"; + +// Register custom formats (shared with jsonschema.ts) +format["email"] = (data: string) => data.includes("@"); +format["page-ref"] = (data: string) => + data.startsWith("[[") && data.endsWith("]]"); + +function cfwFormatErrors(errors: OutputUnit[]): string { + // Filter out "properties" wrapper errors, keep only the specific leaf errors + const leafErrors = errors.filter((e) => e.keyword !== "properties"); + const errorsToUse = leafErrors.length > 0 ? leafErrors : errors; + + return errorsToUse.map((e) => { + // Convert instanceLocation from "#/foo/bar" to "foo.bar" + const path = e.instanceLocation === "#" + ? "" + : e.instanceLocation.slice(2).replaceAll("/", "."); + return path ? `${path}: ${e.error}` : e.error; + }).join(", "); +} + +/** + * Validates that a value looks like a valid JSON schema. + * Uses a lightweight structural check rather than full meta-schema validation. + */ +function isValidJsonSchema(schema: any): { valid: boolean; error?: string } { + if (schema === null || schema === undefined) { + return { valid: false, error: "schema must not be null or undefined" }; + } + if (typeof schema === "boolean") { + return { valid: true }; + } + if (typeof schema !== "object" || Array.isArray(schema)) { + return { valid: false, error: "schema must be an object or boolean" }; + } + // Check that type, if specified, is valid + if (schema.type !== undefined) { + const validTypes = [ + "string", + "number", + "integer", + "boolean", + "object", + "array", + "null", + ]; + const types = Array.isArray(schema.type) ? schema.type : [schema.type]; + for (const t of types) { + if (!validTypes.includes(t)) { + return { + valid: false, + error: `schema.type must be one of ${validTypes.join(", ")}`, + }; + } + } + } + return { valid: true }; +} + +/** + * Deep-clone a value, replacing any functions with null. + * JSON schema can't validate functions, so we strip them before validation. + */ +function stripFunctions(value: any): any { + if (typeof value === "function") return null; + if (value === null || value === undefined || typeof value !== "object") { + return value; + } + if (Array.isArray(value)) { + return value.map(stripFunctions); + } + const result: Record = {}; + for (const key of Object.keys(value)) { + result[key] = stripFunctions(value[key]); + } + return result; +} /** * Configuration management (config.* APIs) for the client @@ -8,23 +84,8 @@ export class Config { type: "object", properties: {}, }; - private ajv = new Ajv(); constructor(public values: Record = {}) { - // Add the same formats as in jsonschema.ts - this.ajv.addFormat("email", { - validate: (data: string) => { - return data.includes("@"); - }, - async: false, - }); - - this.ajv.addFormat("page-ref", { - validate: (data: string) => { - return data.startsWith("[[") && data.endsWith("]]"); - }, - async: false, - }); } public clear() { @@ -42,10 +103,9 @@ export class Config { */ define(key: string | string[], schema: any): void { // Validate the schema itself first - const valid = this.ajv.validateSchema(schema); - if (!valid) { - const errorText = this.ajv.errorsText(this.ajv.errors); - throw new Error(`Invalid schema for key ${key}: ${errorText}`); + const result = isValidJsonSchema(schema); + if (!result.valid) { + throw new Error(`Invalid schema for key ${key}: ${result.error}`); } if (typeof key === "string") { @@ -188,11 +248,10 @@ export class Config { if (schema) { const valueAtPath = this.get(schemaPath, undefined); if (valueAtPath !== undefined) { - const validate = this.ajv.compile(schema); - if (!validate(valueAtPath)) { - let errorText = this.ajv.errorsText(validate.errors); - errorText = errorText.replaceAll("/", "."); - errorText = errorText.replace(/^data\.?/, ""); + const validator = new Validator(schema, "7"); + const result = validator.validate(stripFunctions(valueAtPath)); + if (!result.valid) { + const errorText = cfwFormatErrors(result.errors); throw new Error( `Validation error for ${schemaPath.join(".")}:> ${errorText}`, ); diff --git a/client/plugos/syscalls/editor.ts b/client/plugos/syscalls/editor.ts index f0274952..82c90894 100644 --- a/client/plugos/syscalls/editor.ts +++ b/client/plugos/syscalls/editor.ts @@ -18,7 +18,7 @@ import { } from "@codemirror/commands"; import type { Transaction } from "@codemirror/state"; import { EditorView } from "@codemirror/view"; -import { getCM as vimGetCm, Vim } from "@replit/codemirror-vim"; +import { getVimModule } from "../../vim_loader.ts"; import type { SysCallMapping } from "../system.ts"; import type { FilterOption, @@ -497,14 +497,23 @@ export function editorSyscalls(client: Client): SysCallMapping { void client.reloadEditor(); }, "editor.vimEx": (_ctx, exCommand: string) => { - const cm = vimGetCm(client.editorView); + const vimMod = getVimModule(); + if (!vimMod) { + throw new Error("Vim module not loaded."); + } + const cm = vimMod.getCM(client.editorView); if (cm?.state.vim) { - return Vim.handleEx(cm as any, exCommand); + return vimMod.Vim.handleEx(cm as any, exCommand); } else { throw new Error("Vim mode not active or not initialized."); } }, "editor.configureVimMode": () => { + const vimMod = getVimModule(); + if (!vimMod) { + throw new Error("Vim module not loaded."); + } + const { Vim } = vimMod; // Override the default "o" binding to be more intelligent and follow the markdown editor's behavior Vim.mapCommand("o", "action", "newline-continue-markup", {}, {}); Vim.mapCommand("O", "action", "back-newline-continue-markup", {}, {}); diff --git a/client/plugos/syscalls/jsonschema.ts b/client/plugos/syscalls/jsonschema.ts index 636312b2..087cc50a 100644 --- a/client/plugos/syscalls/jsonschema.ts +++ b/client/plugos/syscalls/jsonschema.ts @@ -1,40 +1,59 @@ import type { SysCallMapping } from "../system.ts"; -import { Ajv, type ValidateFunction } from "ajv"; +import { type OutputUnit, Validator, format } from "@cfworker/json-schema"; -const ajv = new Ajv(); +// Register custom formats +format["email"] = (data: string) => data.includes("@"); +format["page-ref"] = (data: string) => + data.startsWith("[[") && data.endsWith("]]"); -ajv.addFormat("email", { - validate: (data: string) => { - // TODO: Implement email validation - return data.includes("@"); - }, - async: false, -}); +const schemaCache = new Map(); -ajv.addFormat("page-ref", { - validate: (data: string) => { - return data.startsWith("[[") && data.endsWith("]]"); - }, - async: false, -}); +/** + * Deep-clone a value, replacing any functions with null. + * JSON schema can't validate functions, so we strip them before validation. + */ +function stripFunctions(value: any): any { + if (typeof value === "function") return null; + if (value === null || value === undefined || typeof value !== "object") { + return value; + } + if (Array.isArray(value)) { + return value.map(stripFunctions); + } + const result: Record = {}; + for (const key of Object.keys(value)) { + result[key] = stripFunctions(value[key]); + } + return result; +} -const schemaCache = new Map(); +function formatErrors(errors: OutputUnit[]): string { + // Filter out "properties" wrapper errors, keep only the specific leaf errors + const leafErrors = errors.filter((e) => e.keyword !== "properties"); + const errorsToUse = leafErrors.length > 0 ? leafErrors : errors; + + return errorsToUse.map((e) => { + // Convert instanceLocation from "#/foo/bar" to "foo.bar" + const path = e.instanceLocation === "#" + ? "" + : e.instanceLocation.slice(2).replaceAll("/", "."); + return path ? `${path}: ${e.error}` : e.error; + }).join(", "); +} export function validateObject(schema: any, object: any): undefined | string { try { const schemaKey = JSON.stringify(schema); if (!schemaCache.has(schemaKey)) { - const validate = ajv.compile(schema); - schemaCache.set(schemaKey, validate); + const validator = new Validator(schema, "7"); + schemaCache.set(schemaKey, validator); } - const validate = schemaCache.get(schemaKey)!; - if (validate(object)) { + const validator = schemaCache.get(schemaKey)!; + const result = validator.validate(stripFunctions(object)); + if (result.valid) { return; } else { - let text = ajv.errorsText(validate.errors); - text = text.replaceAll("/", "."); - text = text.replace(/^data[.\s]/, ""); - return text; + return formatErrors(result.errors); } } catch (e: any) { return e.message; @@ -42,12 +61,33 @@ export function validateObject(schema: any, object: any): undefined | string { } export function validateSchema(schema: any): undefined | string { - const valid = ajv.validateSchema(schema); - if (valid) { - return; - } else { - return ajv.errorsText(ajv.errors); + if (schema === null || schema === undefined) { + return "schema must not be null or undefined"; } + if (typeof schema === "boolean") { + return; + } + if (typeof schema !== "object" || Array.isArray(schema)) { + return "schema must be an object or boolean"; + } + if (schema.type !== undefined) { + const validTypes = [ + "string", + "number", + "integer", + "boolean", + "object", + "array", + "null", + ]; + const types = Array.isArray(schema.type) ? schema.type : [schema.type]; + for (const t of types) { + if (!validTypes.includes(t)) { + return `schema.type must be one of ${validTypes.join(", ")}`; + } + } + } + return; } export function jsonschemaSyscalls(): SysCallMapping { diff --git a/client/service_worker.ts b/client/service_worker.ts index 678b013b..865d6889 100644 --- a/client/service_worker.ts +++ b/client/service_worker.ts @@ -40,19 +40,10 @@ const basePathName = location.pathname.substring( ); const precacheFiles = Object.fromEntries( - [ - "/", - "/.client/client.js", - "/.client/favicon.png", - "/.client/iAWriterMonoS-Bold.woff2", - "/.client/iAWriterMonoS-BoldItalic.woff2", - "/.client/iAWriterMonoS-Italic.woff2", - "/.client/iAWriterMonoS-Regular.woff2", - "/.client/logo.png", - "/.client/logo-dock.png", - "/.client/main.css", - "/.client/manifest.json", - ].map((path) => [path, `${baseURI}${path}?v=${CACHE_NAME}`, path]), + // Dynamically replaced during build + "{{PRECACHE_FILES}}" + .split(",") + .map((path) => [path, `${baseURI}${path}?v=${CACHE_NAME}`, path]), ); // Cache busting // Initially set to undefined, resulting in all "fetch" being proxied. diff --git a/client/vim_loader.ts b/client/vim_loader.ts new file mode 100644 index 00000000..59042096 --- /dev/null +++ b/client/vim_loader.ts @@ -0,0 +1,12 @@ +let vimModule: typeof import("@replit/codemirror-vim") | null = null; + +export async function loadVim() { + if (!vimModule) { + vimModule = await import("@replit/codemirror-vim"); + } + return vimModule; +} + +export function getVimModule() { + return vimModule; +} diff --git a/package-lock.json b/package-lock.json index 3276198f..4c0b9ddf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,13 +1,14 @@ { "name": "@silverbulletmd/silverbullet", - "version": "2.4.3", + "version": "2.5.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@silverbulletmd/silverbullet", - "version": "2.4.3", + "version": "2.5.3", "dependencies": { + "@cfworker/json-schema": "^4.1.1", "@codemirror/autocomplete": "6.20.0", "@codemirror/commands": "6.10.1", "@codemirror/lang-css": "6.3.1", @@ -31,7 +32,6 @@ "@msgpack/msgpack": "3.1.3", "@replit/codemirror-lang-nix": "6.0.1", "@replit/codemirror-vim": "6.3.0", - "ajv": "8.17.1", "commander": "^14.0.3", "crelt": "1.0.6", "esbuild": "^0.27.3", @@ -550,6 +550,12 @@ "node": ">=14.21.3" } }, + "node_modules/@cfworker/json-schema": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@cfworker/json-schema/-/json-schema-4.1.1.tgz", + "integrity": "sha512-gAmrUZSGtKc3AiBL71iNWxDsyUC5uMaKKGdvzYsBoTW/xi42JQHl7eKV2OYzCUqvc+D2RCcf7EXY2iCyFIk6og==", + "license": "MIT" + }, "node_modules/@codemirror/autocomplete": { "version": "6.20.0", "resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.0.tgz", @@ -2273,22 +2279,6 @@ "url": "https://opencollective.com/vitest" } }, - "node_modules/ajv": { - "version": "8.17.1", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", - "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.3", - "fast-uri": "^3.0.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", @@ -2668,12 +2658,6 @@ "node": ">=18" } }, - "node_modules/fast-deep-equal": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "license": "MIT" - }, "node_modules/fast-diff": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.3.0.tgz", @@ -2696,22 +2680,6 @@ "node": ">=8.6.0" } }, - "node_modules/fast-uri": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", - "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "BSD-3-Clause" - }, "node_modules/fastq": { "version": "1.20.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", @@ -2902,12 +2870,6 @@ "node": ">=6" } }, - "node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "license": "MIT" - }, "node_modules/json5": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", @@ -3198,15 +3160,6 @@ "url": "https://paulmillr.com/funding/" } }, - "node_modules/require-from-string": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", - "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/resolve-pkg-maps": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", diff --git a/package.json b/package.json index fe5877df..99853bc5 100644 --- a/package.json +++ b/package.json @@ -64,6 +64,7 @@ "bench": "vitest bench" }, "dependencies": { + "@cfworker/json-schema": "^4.1.1", "@codemirror/autocomplete": "6.20.0", "@codemirror/commands": "6.10.1", "@codemirror/lang-css": "6.3.1", @@ -87,7 +88,6 @@ "@msgpack/msgpack": "3.1.3", "@replit/codemirror-lang-nix": "6.0.1", "@replit/codemirror-vim": "6.3.0", - "ajv": "8.17.1", "commander": "^14.0.3", "crelt": "1.0.6", "esbuild": "^0.27.3",