diff --git a/apps/client/package.json b/apps/client/package.json index 52738c71a99..e0e1e302d6e 100644 --- a/apps/client/package.json +++ b/apps/client/package.json @@ -72,6 +72,7 @@ "rrule": "2.8.1", "svg-pan-zoom": "3.6.2", "tabulator-tables": "6.4.0", + "prettier": "^3.5.3", "vanilla-js-wheel-zoom": "9.0.4" }, "devDependencies": { diff --git a/apps/client/src/services/code_formatter.spec.ts b/apps/client/src/services/code_formatter.spec.ts new file mode 100644 index 00000000000..203f38016dd --- /dev/null +++ b/apps/client/src/services/code_formatter.spec.ts @@ -0,0 +1,277 @@ +import { type CodeFormatter, FormatterRegistry } from "./code_formatter.js"; +import { beforeEach, describe, expect, it } from "vitest"; + +function makeFormatter( + name: string, + supportedLanguages: string[], +): CodeFormatter { + return { + name, + canFormat(language: string): boolean { + return supportedLanguages.includes(language); + }, + async format(code: string, _language: string): Promise { + return `[${name}] ${code}`; + }, + }; +} + +function makeNeverFormatter(name = "NeverFormatter"): CodeFormatter { + return makeFormatter(name, []); +} + +describe("FormatterRegistry", () => { + let registry: FormatterRegistry; + + beforeEach(() => { + registry = new FormatterRegistry(); + }); + + describe("initial state", () => { + it("should have no formatters registered", () => { + expect(registry.isLanguageSupported("javascript")).toBe(false); + }); + + it("should return undefined for getFormatterForLanguage when empty", () => { + expect( + registry.getFormatterForLanguage("typescript"), + ).toBeUndefined(); + }); + }); + + describe("isLanguageSupported", () => { + it("should return false for an unknown language when formatters are registered", () => { + registry.register(makeFormatter("A", ["javascript"])); + + expect(registry.isLanguageSupported("python")).toBe(false); + }); + + it("should return true for a language handled by a registered formatter", () => { + registry.register(makeFormatter("A", ["javascript"])); + + expect(registry.isLanguageSupported("javascript")).toBe(true); + }); + + it("should return true when only one of many formatters handles the language", () => { + registry.register(makeFormatter("A", ["css"])); + registry.register(makeFormatter("B", ["html"])); + + expect(registry.isLanguageSupported("html")).toBe(true); + }); + + it("should return false when all registered formatters reject the language", () => { + registry.register(makeNeverFormatter("X")); + registry.register(makeNeverFormatter("Y")); + + expect(registry.isLanguageSupported("rust")).toBe(false); + }); + }); + + describe("getFormatterForLanguage", () => { + it("should return undefined for an unregistered language", () => { + registry.register(makeFormatter("A", ["javascript"])); + + expect(registry.getFormatterForLanguage("rust")).toBeUndefined(); + }); + + it("should return the matching formatter for a registered language", () => { + const formatter = makeFormatter("Prettier", ["typescript"]); + registry.register(formatter); + + expect(registry.getFormatterForLanguage("typescript")).toBe( + formatter, + ); + }); + + it("should return the first matching formatter when multiple formatters support the language", () => { + const first = makeFormatter("First", ["javascript"]); + const second = makeFormatter("Second", ["javascript"]); + registry.register(first); + registry.register(second); + + expect(registry.getFormatterForLanguage("javascript")).toBe(first); + }); + + it("should return the correct formatter when languages do not overlap", () => { + const cssFormatter = makeFormatter("CSS", ["css"]); + const jsFormatter = makeFormatter("JS", ["javascript"]); + registry.register(cssFormatter); + registry.register(jsFormatter); + + expect(registry.getFormatterForLanguage("css")).toBe(cssFormatter); + expect(registry.getFormatterForLanguage("javascript")).toBe( + jsFormatter, + ); + }); + }); + + describe("register", () => { + it("should allow registering a single formatter", () => { + registry.register(makeFormatter("A", ["json"])); + + expect(registry.isLanguageSupported("json")).toBe(true); + }); + + it("should allow registering multiple formatters independently", () => { + registry.register(makeFormatter("A", ["json"])); + registry.register(makeFormatter("B", ["yaml"])); + + expect(registry.isLanguageSupported("json")).toBe(true); + expect(registry.isLanguageSupported("yaml")).toBe(true); + }); + + it("should give priority to the first registered formatter when both handle the same language", () => { + const first = makeFormatter("First", ["scss"]); + const second = makeFormatter("Second", ["scss"]); + registry.register(first); + registry.register(second); + + const resolved = registry.getFormatterForLanguage("scss"); + + expect(resolved?.name).toBe("First"); + }); + + it("should skip non-matching formatters and reach the one that matches", () => { + const noMatch = makeNeverFormatter("NoMatch"); + const match = makeFormatter("Match", ["graphql"]); + registry.register(noMatch); + registry.register(match); + + expect(registry.getFormatterForLanguage("graphql")).toBe(match); + }); + }); + + describe("format", () => { + it("should delegate to the matching formatter", async () => { + registry.register(makeFormatter("F", ["javascript"])); + + const result = await registry.format("x", "javascript"); + + expect(result).toBe("[F] x"); + }); + + it("should delegate to the first matching formatter when multiple match", async () => { + registry.register(makeFormatter("First", ["javascript"])); + registry.register(makeFormatter("Second", ["javascript"])); + + const result = await registry.format("x", "javascript"); + + expect(result).toBe("[First] x"); + }); + + it("should reject with an error for an unsupported language", async () => { + await expect(registry.format("x", "rust")).rejects.toThrow( + "No formatter available for language: rust", + ); + }); + + it("should reject with an error when registry is empty", async () => { + await expect(registry.format("x", "javascript")).rejects.toThrow( + "No formatter available for language: javascript", + ); + }); + }); + + describe("canFormat delegation", () => { + it("should delegate canFormat to each registered formatter in order", () => { + const callLog: string[] = []; + + const trackingFormatter = ( + name: string, + languages: string[], + ): CodeFormatter => ({ + name, + canFormat(language: string): boolean { + callLog.push(name); + return languages.includes(language); + }, + async format(code: string): Promise { + return code; + }, + }); + + const formatterA = trackingFormatter("A", []); + const formatterB = trackingFormatter("B", ["markdown"]); + registry.register(formatterA); + registry.register(formatterB); + + registry.getFormatterForLanguage("markdown"); + + expect(callLog).toEqual(["A", "B"]); + }); + + it("should stop delegation at the first formatter that handles the language", () => { + const callLog: string[] = []; + + const trackingFormatter = ( + name: string, + languages: string[], + ): CodeFormatter => ({ + name, + canFormat(language: string): boolean { + callLog.push(name); + return languages.includes(language); + }, + async format(code: string): Promise { + return code; + }, + }); + + const formatterA = trackingFormatter("A", ["html"]); + const formatterB = trackingFormatter("B", ["html"]); + registry.register(formatterA); + registry.register(formatterB); + + registry.getFormatterForLanguage("html"); + + // Array.prototype.find stops at the first truthy result, so B + // should never be consulted. + expect(callLog).toEqual(["A"]); + }); + }); + + describe("CodeFormatter interface contract", () => { + it("should expose a readonly name property", () => { + const formatter = makeFormatter("TestFormatter", ["javascript"]); + + expect(formatter.name).toBe("TestFormatter"); + }); + + it("canFormat should return true for a supported language", () => { + const formatter = makeFormatter("F", ["css", "scss"]); + + expect(formatter.canFormat("css")).toBe(true); + expect(formatter.canFormat("scss")).toBe(true); + }); + + it("canFormat should return false for an unsupported language", () => { + const formatter = makeFormatter("F", ["css"]); + + expect(formatter.canFormat("rust")).toBe(false); + }); + + it("format should return a promise that resolves to a string", async () => { + const formatter = makeFormatter("F", ["javascript"]); + + const result = await formatter.format("const x = 1", "javascript"); + + expect(typeof result).toBe("string"); + }); + + it("format should resolve with the formatted output", async () => { + const formatter = makeFormatter("F", ["javascript"]); + + const result = await formatter.format("const x = 1", "javascript"); + + expect(result).toBe("[F] const x = 1"); + }); + + it("format should preserve empty string input", async () => { + const formatter = makeFormatter("F", ["javascript"]); + + const result = await formatter.format("", "javascript"); + + expect(result).toBe("[F] "); + }); + }); +}); diff --git a/apps/client/src/services/code_formatter.ts b/apps/client/src/services/code_formatter.ts new file mode 100644 index 00000000000..8ace50179e0 --- /dev/null +++ b/apps/client/src/services/code_formatter.ts @@ -0,0 +1,29 @@ +export interface CodeFormatter { + readonly name: string; + canFormat(language: string): boolean; + format(code: string, language: string): Promise; +} + +export class FormatterRegistry { + private readonly formatters: CodeFormatter[] = []; + + register(formatter: CodeFormatter): void { + this.formatters.push(formatter); + } + + getFormatterForLanguage(language: string): CodeFormatter | undefined { + return this.formatters.find((f) => f.canFormat(language)); + } + + isLanguageSupported(language: string): boolean { + return this.formatters.some((f) => f.canFormat(language)); + } + + format(code: string, language: string): Promise { + const formatter = this.getFormatterForLanguage(language); + if (!formatter) { + return Promise.reject(new Error(`No formatter available for language: ${language}`)); + } + return formatter.format(code, language); + } +} diff --git a/apps/client/src/services/prettier_formatter.ts b/apps/client/src/services/prettier_formatter.ts new file mode 100644 index 00000000000..cd438aa8e17 --- /dev/null +++ b/apps/client/src/services/prettier_formatter.ts @@ -0,0 +1,80 @@ +import type { CodeFormatter } from "./code_formatter.js"; +import type { Plugin } from "prettier"; + +interface PrettierParserConfig { + parser: string; + plugins: () => Promise<(string | URL | Plugin)[]>; +} + +const babelPlugins = () => + Promise.all([ + import("prettier/plugins/babel"), + import("prettier/plugins/estree"), + ]); + +const typescriptPlugins = () => + Promise.all([ + import("prettier/plugins/typescript"), + import("prettier/plugins/estree"), + ]); + +const postcssPlugins = () => + import("prettier/plugins/postcss").then((m) => [m]); + +const LANGUAGE_MAP: Record = { + "application-javascript-env-frontend": { parser: "babel", plugins: babelPlugins }, + "application-javascript-env-backend": { parser: "babel", plugins: babelPlugins }, + "text-jsx": { parser: "babel", plugins: babelPlugins }, + "application-typescript": { parser: "typescript", plugins: typescriptPlugins }, + "text-typescript-jsx": { parser: "typescript", plugins: typescriptPlugins }, + "application-json": { parser: "json", plugins: babelPlugins }, + "text-css": { parser: "css", plugins: postcssPlugins }, + "text-x-less": { parser: "less", plugins: postcssPlugins }, + "text-x-scss": { parser: "scss", plugins: postcssPlugins }, + "text-html": { + parser: "html", + plugins: () => import("prettier/plugins/html").then((m) => [m]), + }, + "text-x-yaml": { + parser: "yaml", + plugins: () => import("prettier/plugins/yaml").then((m) => [m]), + }, + "text-x-markdown": { + parser: "markdown", + plugins: () => import("prettier/plugins/markdown").then((m) => [m]), + }, +}; + +export class PrettierFormatter implements CodeFormatter { + readonly name = "Prettier"; + + canFormat(language: string): boolean { + return language in LANGUAGE_MAP; + } + + async format(code: string, language: string): Promise { + const config = LANGUAGE_MAP[language]; + if (!config) { + throw new Error( + `PrettierFormatter: no parser config for language "${language}"`, + ); + } + + const [prettier, plugins] = await Promise.all([ + import("prettier/standalone"), + config.plugins(), + ]); + + try { + return await prettier.format(code, { + parser: config.parser, + plugins, + tabWidth: 4, + printWidth: 120, + }); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + throw new Error(`Prettier: ${msg}`); + } + } +} diff --git a/apps/client/src/widgets/type_widgets/text/CKEditorWithWatchdog.tsx b/apps/client/src/widgets/type_widgets/text/CKEditorWithWatchdog.tsx index 56b4d57060e..fadcf800210 100644 --- a/apps/client/src/widgets/type_widgets/text/CKEditorWithWatchdog.tsx +++ b/apps/client/src/widgets/type_widgets/text/CKEditorWithWatchdog.tsx @@ -1,4 +1,4 @@ -import { CKTextEditor, ClassicEditor, EditorWatchdog, PopupEditor, TemplateDefinition,type WatchdogConfig } from "@triliumnext/ckeditor5"; +import { CKTextEditor, ClassicEditor, EditorWatchdog, PopupEditor, TemplateDefinition, type EventInfo, type NotificationShowEventData, type WatchdogConfig } from "@triliumnext/ckeditor5"; import { DISPLAYABLE_LOCALE_IDS } from "@triliumnext/commons"; import { HTMLProps, RefObject, useEffect, useImperativeHandle, useRef, useState } from "preact/compat"; @@ -25,7 +25,7 @@ interface CKEditorWithWatchdogProps extends Pick, "cla isClassicEditor?: boolean; watchdogRef: RefObject; watchdogConfig?: WatchdogConfig; - onNotificationWarning?: (evt: any, data: any) => void; + onNotificationWarning?: (evt: EventInfo, data: NotificationShowEventData) => void; onWatchdogStateChange?: (watchdog: EditorWatchdog) => void; onChange: () => void; /** Called upon whenever a new CKEditor instance is initialized, whether it's the first initialization, after a crash or after a config change that requires it (e.g. content language). */ diff --git a/apps/client/src/widgets/type_widgets/text/EditableText.tsx b/apps/client/src/widgets/type_widgets/text/EditableText.tsx index fba7d6966e2..ee0a22ae7d8 100644 --- a/apps/client/src/widgets/type_widgets/text/EditableText.tsx +++ b/apps/client/src/widgets/type_widgets/text/EditableText.tsx @@ -1,6 +1,6 @@ import "./EditableText.css"; -import { CKTextEditor, EditorWatchdog, TemplateDefinition } from "@triliumnext/ckeditor5"; +import { CKTextEditor, EditorWatchdog, TemplateDefinition, type EventInfo, type NotificationShowEventData } from "@triliumnext/ckeditor5"; import { deferred } from "@triliumnext/commons"; import { RefObject } from "preact"; import { useCallback, useEffect, useRef, useState } from "preact/hooks"; @@ -333,14 +333,13 @@ function useWatchdogCrashHandling() { return onWatchdogStateChange; } -function onNotificationWarning(data, evt) { - const title = data.title; - const message = data.message.message; +function onNotificationWarning(evt: EventInfo, data: NotificationShowEventData) { + const { title, message } = data; - if (title && message) { - toast.showErrorTitleAndMessage(data.title, data.message.message); - } else if (title) { - toast.showError(title || message); + if (title) { + toast.showErrorTitleAndMessage(title, message); + } else { + toast.showError(message); } evt.stop(); diff --git a/apps/client/src/widgets/type_widgets/text/config.ts b/apps/client/src/widgets/type_widgets/text/config.ts index 29b1a026993..48c36a070e3 100644 --- a/apps/client/src/widgets/type_widgets/text/config.ts +++ b/apps/client/src/widgets/type_widgets/text/config.ts @@ -9,6 +9,8 @@ import { default as mimeTypesService, getHighlightJsNameForMime } from "../../.. import noteAutocompleteService, { type Suggestion } from "../../../services/note_autocomplete.js"; import options from "../../../services/options.js"; import { ensureMimeTypesForHighlighting, isSyntaxHighlightEnabled } from "../../../services/syntax_highlight.js"; +import { FormatterRegistry } from "../../../services/code_formatter.js"; +import { PrettierFormatter } from "../../../services/prettier_formatter.js"; import { buildToolbarConfig } from "./toolbar.js"; export const OPEN_SOURCE_LICENSE_KEY = "GPL"; @@ -28,6 +30,7 @@ export async function buildConfig(opts: BuildEditorOptions): PromiseFormatting toolbar: ![](2_Code%20blocks_image.png) +## Formatting a code block + +Trilium can automatically format (pretty-print) the code inside a code block using [Prettier](https://prettier.io/). To trigger formatting, click inside the code block and press the _Format code block_ button in the toolbar. + + + +### Supported languages + +Formatting is powered by Prettier and is available for the following languages (for now): + +| Language | MIME type | +| --- | --- | +| JavaScript (frontend) | `application/javascript;env=frontend` | +| JavaScript (backend) | `application/javascript;env=backend` | +| JSX | `text/jsx` | +| TypeScript | `application/typescript` | +| TSX | `text/typescript-jsx` | +| JSON | `application/json` | +| CSS | `text/css` | +| Less | `text/x-less` | +| SCSS | `text/x-scss` | +| HTML | `text/html` | +| YAML | `text/x-yaml` | +| Markdown | `text/x-markdown` | + +> [!NOTE] +> Formatting is **not available** when the language of the code block is set to _Auto-detected_ or _Plain text_. Make sure to explicitly select a supported language for the code block before using this feature. + ## Adjusting the list of languages The code blocks feature shares the list of languages with the Code note type. diff --git a/package.json b/package.json index f0c3c158b69..3b4be6f5793 100644 --- a/package.json +++ b/package.json @@ -34,8 +34,8 @@ "docs:preview": "pnpm http-server site -p 9000", "edit-docs:edit-demo": "pnpm run --filter edit-docs edit-demo", "test:all": "pnpm test:parallel && pnpm test:sequential", - "test:parallel": "pnpm --filter=!server --filter=!ckeditor5-mermaid --filter=!ckeditor5-math --parallel test", - "test:sequential": "pnpm --filter=server --filter=ckeditor5-mermaid --filter=ckeditor5-math --sequential test", + "test:parallel": "pnpm --filter=!server --filter=!ckeditor5-mermaid --filter=!ckeditor5-math --filter=!@triliumnext/ckeditor5 --parallel test", + "test:sequential": "pnpm --filter=server --filter=ckeditor5-mermaid --filter=ckeditor5-math --filter=@triliumnext/ckeditor5 --sequential test", "typecheck": "tsx scripts/filter-tsc-output.mts", "dev:format-check": "eslint -c eslint.format.config.mjs .", "dev:format-fix": "eslint -c eslint.format.config.mjs . --fix", diff --git a/packages/ckeditor5/package.json b/packages/ckeditor5/package.json index 13fc4e4a934..6221291b3a0 100644 --- a/packages/ckeditor5/package.json +++ b/packages/ckeditor5/package.json @@ -5,6 +5,9 @@ "private": true, "type": "module", "main": "./src/index.ts", + "scripts": { + "test": "vitest" + }, "dependencies": { "@triliumnext/commons": "workspace:*", "@triliumnext/ckeditor5-admonition": "workspace:*", @@ -14,5 +17,10 @@ "@triliumnext/ckeditor5-mermaid": "workspace:*", "ckeditor5": "48.0.0", "ckeditor5-premium-features": "48.0.0" + }, + "devDependencies": { + "@smithy/middleware-retry": "4.4.44", + "@types/jquery": "4.0.0", + "prettier": "^3.5.3" } } diff --git a/packages/ckeditor5/src/icons/format-codeblock.svg b/packages/ckeditor5/src/icons/format-codeblock.svg new file mode 100644 index 00000000000..e14a0c97d06 --- /dev/null +++ b/packages/ckeditor5/src/icons/format-codeblock.svg @@ -0,0 +1 @@ + diff --git a/packages/ckeditor5/src/index.ts b/packages/ckeditor5/src/index.ts index ca51d92d7b0..b33931b215b 100644 --- a/packages/ckeditor5/src/index.ts +++ b/packages/ckeditor5/src/index.ts @@ -2,12 +2,35 @@ import "ckeditor5/ckeditor5.css"; // Premium features CSS loaded dynamically with the plugins // import 'ckeditor5-premium-features/ckeditor5-premium-features.css'; import "./theme/code_block_toolbar.css"; -import { COMMON_PLUGINS, CORE_PLUGINS, POPUP_EDITOR_PLUGINS } from "./plugins.js"; -import { BalloonEditor, DecoupledEditor, FindAndReplaceEditing, FindCommand } from "ckeditor5"; +import { + COMMON_PLUGINS, + CORE_PLUGINS, + POPUP_EDITOR_PLUGINS, +} from "./plugins.js"; +import { + BalloonEditor, + DecoupledEditor, + FindAndReplaceEditing, + FindCommand, +} from "ckeditor5"; import "./translation_overrides.js"; +import type { CodeFormatter } from "./plugins/format_codeblock/types.js"; export { default as EditorWatchdog } from "./custom_watchdog"; export { loadPremiumPlugins } from "./plugins.js"; -export type { EditorConfig, MentionFeed, MentionFeedObjectItem, ModelNode, ModelPosition, ModelElement, ModelText, WatchdogConfig, WatchdogState } from "ckeditor5"; +export type { + EditorConfig, + EventInfo, + MentionFeed, + MentionFeedObjectItem, + ModelNode, + ModelPosition, + ModelElement, + ModelText, + NotificationShowEventData, + WatchdogConfig, + WatchdogState, +} from "ckeditor5"; +export type { CodeFormatter as CodeFormatterConfig } from "./plugins/format_codeblock/types.js"; export type { TemplateDefinition } from "ckeditor5-premium-features"; export { default as buildExtraCommands } from "./extra_slash_commands.js"; export { default as getCkLocale } from "./i18n.js"; @@ -35,7 +58,6 @@ export type FindCommandResult = ReturnType; * The text editor that can be used for editing attributes and relations. */ export class AttributeEditor extends BalloonEditor { - static override get builtinPlugins() { return CORE_PLUGINS; } @@ -66,20 +88,21 @@ declare module "ckeditor5" { } interface EditorConfig { + codeFormatter?: CodeFormatter; syntaxHighlighting?: { loadHighlightJs: () => Promise; mapLanguageName(mimeType: string): string; defaultMimeType: string; enabled: boolean; - }, + }; moveBlockUp?: { keystroke: string[]; - }, + }; moveBlockDown?: { keystroke: string[]; - }, + }; clipboard?: { copy(text: string): void; - } + }; } } diff --git a/packages/ckeditor5/src/plugins/code_block_toolbar.ts b/packages/ckeditor5/src/plugins/code_block_toolbar.ts index 4bdd30f0c6a..e7be75aa7f2 100644 --- a/packages/ckeditor5/src/plugins/code_block_toolbar.ts +++ b/packages/ckeditor5/src/plugins/code_block_toolbar.ts @@ -1,22 +1,37 @@ -import { BalloonToolbarShowEvent, CodeBlock, Plugin, ViewDocumentFragment, WidgetToolbarRepository, type ViewNode } from "ckeditor5"; +import { + CodeBlock, + Plugin, + ViewDocumentFragment, + WidgetToolbarRepository, + type ViewNode, +} from "ckeditor5"; import CodeBlockLanguageDropdown from "./code_block_language_dropdown"; import CopyToClipboardButton from "./copy_to_clipboard_button"; +import FormatCodeblockButton from "./format_codeblock/format_codeblock_button"; export default class CodeBlockToolbar extends Plugin { - static get requires() { - return [ WidgetToolbarRepository, CodeBlock, CodeBlockLanguageDropdown, CopyToClipboardButton ] as const; + return [ + WidgetToolbarRepository, + CodeBlock, + CodeBlockLanguageDropdown, + CopyToClipboardButton, + FormatCodeblockButton, + ] as const; } afterInit() { const editor = this.editor; - const widgetToolbarRepository = editor.plugins.get(WidgetToolbarRepository); + const widgetToolbarRepository = editor.plugins.get( + WidgetToolbarRepository, + ); widgetToolbarRepository.register("codeblock", { items: [ "codeBlockDropdown", "|", - "copyToClipboard" + "copyToClipboard", + "formatCodeblock", ], balloonClassName: "ck-toolbar-container codeblock-language-list", getRelatedElement(selection) { @@ -25,7 +40,8 @@ export default class CodeBlockToolbar extends Plugin { return null; } - let parent: ViewNode | ViewDocumentFragment | null = selectionPosition.parent; + let parent: ViewNode | ViewDocumentFragment | null = + selectionPosition.parent; while (parent) { if (parent.is("element", "pre")) { return parent; @@ -35,20 +51,26 @@ export default class CodeBlockToolbar extends Plugin { } return null; - } + }, }); // Hide balloon toolbar when in a code block if (editor.plugins.has("BalloonToolbar")) { - editor.listenTo(editor.plugins.get('BalloonToolbar'), 'show', (evt) => { - const firstPosition = editor.model.document.selection.getFirstPosition(); - const isInCodeBlock = firstPosition?.findAncestor('codeBlock'); + editor.listenTo( + editor.plugins.get("BalloonToolbar"), + "show", + (evt) => { + const firstPosition = + editor.model.document.selection.getFirstPosition(); + const isInCodeBlock = + firstPosition?.findAncestor("codeBlock"); - if (isInCodeBlock) { - evt.stop(); // Prevent the balloon toolbar from showing - } - }, { priority: 'high' }); + if (isInCodeBlock) { + evt.stop(); // Prevent the balloon toolbar from showing + } + }, + { priority: "high" }, + ); } } - } diff --git a/packages/ckeditor5/src/plugins/format_codeblock/format_codeblock_button.ts b/packages/ckeditor5/src/plugins/format_codeblock/format_codeblock_button.ts new file mode 100644 index 00000000000..7e61d02f29f --- /dev/null +++ b/packages/ckeditor5/src/plugins/format_codeblock/format_codeblock_button.ts @@ -0,0 +1,37 @@ +import { ButtonView, Notification, Plugin } from "ckeditor5"; +import formatIcon from "../../icons/format-codeblock.svg?raw"; +import { FormatCodeblockCommand } from "./format_codeblock_command"; + +export default class FormatCodeblockButton extends Plugin { + static get requires() { + return [Notification]; + } + + public init() { + const editor = this.editor; + + editor.commands.add( + "formatCodeblock", + new FormatCodeblockCommand(this.editor), + ); + + const componentFactory = editor.ui.componentFactory; + componentFactory.add("formatCodeblock", (locale) => { + const button = new ButtonView(locale); + const command = editor.commands.get("formatCodeblock")!; + + button.set({ + tooltip: "Format code block", + icon: formatIcon, + }); + + button.bind("isEnabled").to(command, "isEnabled"); + + this.listenTo(button, "execute", () => { + editor.execute("formatCodeblock"); + }); + + return button; + }); + } +} diff --git a/packages/ckeditor5/src/plugins/format_codeblock/format_codeblock_command.ts b/packages/ckeditor5/src/plugins/format_codeblock/format_codeblock_command.ts new file mode 100644 index 00000000000..e53aa7e0a00 --- /dev/null +++ b/packages/ckeditor5/src/plugins/format_codeblock/format_codeblock_command.ts @@ -0,0 +1,108 @@ +import { Command, ModelElement, Notification } from "ckeditor5"; + +export class FormatCodeblockCommand extends Command { + declare value: string | false; + + private _isFormatting = false; + + override refresh() { + if (this._isFormatting) { + this.isEnabled = false; + return; + } + + const codeBlockCommand = this.editor.commands.get("codeBlock"); + const language = codeBlockCommand?.value; + const codeFormatter = this.editor.config.get("codeFormatter"); + + if ( + typeof language === "string" && + codeFormatter?.isLanguageSupported(language) + ) { + this.isEnabled = true; + this.value = language; + } else { + this.isEnabled = false; + this.value = false; + } + } + + override execute() { + const editor = this.editor; + const model = editor.model; + const selection = model.document.selection; + const notification = editor.plugins.get(Notification); + const t = editor.locale.t; + const codeFormatter = editor.config.get("codeFormatter"); + + const language = this.value; + if (!language || !codeFormatter) { + return; + } + + const codeBlockEl = selection + .getFirstPosition() + ?.findAncestor("codeBlock"); + if (!codeBlockEl) { + notification.showWarning( + t("Unable to find code block element to format."), + { + namespace: "formatCodeblock", + }, + ); + return; + } + + const codeText = this.extractCodeText(codeBlockEl); + + if (!codeText.trim()) { + return; + } + + this._isFormatting = true; + this.refresh(); + + codeFormatter + .format(codeText, language) + .then((formatted) => { + // Strip trailing newline that formatters like Prettier always add, + // to avoid accumulating phantom softBreak elements in the model. + const normalizedFormatted = formatted.replace(/\n$/, ""); + + if (normalizedFormatted === codeText) { + return; + } + + model.change((writer) => { + const range = writer.createRangeIn(codeBlockEl); + writer.remove(range); + const lines = normalizedFormatted.split("\n"); + for (let i = 0; i < lines.length; i++) { + if (i > 0) { + writer.appendElement("softBreak", codeBlockEl); + } + if (lines[i]) { + writer.appendText(lines[i], codeBlockEl); + } + } + }); + }) + .catch((err: unknown) => { + const message = + err instanceof Error ? err.message : String(err); + notification.showWarning(message, { + namespace: "formatCodeblock", + }); + }) + .finally(() => { + this._isFormatting = false; + this.refresh(); + }); + } + + private extractCodeText(codeBlockEl: ModelElement): string { + return Array.from(codeBlockEl.getChildren()) + .map((child) => ("data" in child ? child.data : "\n")) + .join(""); + } +} diff --git a/packages/ckeditor5/src/plugins/format_codeblock/types.ts b/packages/ckeditor5/src/plugins/format_codeblock/types.ts new file mode 100644 index 00000000000..92839558e3c --- /dev/null +++ b/packages/ckeditor5/src/plugins/format_codeblock/types.ts @@ -0,0 +1,4 @@ +export interface CodeFormatter { + isLanguageSupported(language: string): boolean; + format(code: string, language: string): Promise; +} diff --git a/packages/ckeditor5/tests/format_codeblock_button.test.ts b/packages/ckeditor5/tests/format_codeblock_button.test.ts new file mode 100644 index 00000000000..fd09a7dd170 --- /dev/null +++ b/packages/ckeditor5/tests/format_codeblock_button.test.ts @@ -0,0 +1,86 @@ +import { + ClassicEditor, + _setModelData as setModelData, +} from "ckeditor5"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { FormatCodeblockCommand } from "../src/plugins/format_codeblock/format_codeblock_command"; +import { + createEditor, + setCodeFormatter, +} from "./format_codeblock_helpers"; + +describe("FormatCodeblockButton", () => { + let domElement: HTMLDivElement; + let editor: ClassicEditor; + + beforeEach(async () => { + const result = await createEditor({ + isLanguageSupported: () => true, + format: async (code) => code, + }); + editor = result.editor; + domElement = result.div; + }); + + afterEach(() => { + domElement.remove(); + return editor.destroy(); + }); + + describe("plugin registration", () => { + it("should register the formatCodeblock command", () => { + const command = editor.commands.get("formatCodeblock"); + expect(command).toBeDefined(); + expect(command).toBeInstanceOf(FormatCodeblockCommand); + }); + + it("should register the formatCodeblock UI component", () => { + expect(editor.ui.componentFactory.has("formatCodeblock")).toBe(true); + }); + }); + + describe("button UI", () => { + it("should have the correct tooltip", () => { + const button = editor.ui.componentFactory.create("formatCodeblock"); + expect((button as any).tooltip).toBe("Format code block"); + }); + + it("button isEnabled should follow command isEnabled", () => { + setModelData(editor.model, 'foo[]'); + + const button = editor.ui.componentFactory.create("formatCodeblock"); + const command = editor.commands.get("formatCodeblock")!; + + expect(command.isEnabled).toBe(true); + expect((button as any).isEnabled).toBe(true); + + setModelData(editor.model, "foo[]"); + + expect(command.isEnabled).toBe(false); + expect((button as any).isEnabled).toBe(false); + }); + }); + + describe("without codeFormatter config", () => { + it("should still register the command and UI component", async () => { + const { editor: noConfigEditor, div } = await createEditor(); + + expect(noConfigEditor.commands.get("formatCodeblock")).toBeDefined(); + expect(noConfigEditor.commands.get("formatCodeblock")).toBeInstanceOf(FormatCodeblockCommand); + expect(noConfigEditor.ui.componentFactory.has("formatCodeblock")).toBe(true); + + await noConfigEditor.destroy(); + div.remove(); + }); + + it("command should always be disabled when no formatter config is provided", async () => { + const { editor: noConfigEditor, div } = await createEditor(); + + setModelData(noConfigEditor.model, 'foo[]'); + expect(noConfigEditor.commands.get("formatCodeblock")!.isEnabled).toBe(false); + + await noConfigEditor.destroy(); + div.remove(); + }); + }); +}); diff --git a/packages/ckeditor5/tests/format_codeblock_command.test.ts b/packages/ckeditor5/tests/format_codeblock_command.test.ts new file mode 100644 index 00000000000..2c67e67d054 --- /dev/null +++ b/packages/ckeditor5/tests/format_codeblock_command.test.ts @@ -0,0 +1,275 @@ +import { + ClassicEditor, + Notification, + _setModelData as setModelData, +} from "ckeditor5"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { FormatCodeblockCommand } from "../src/plugins/format_codeblock/format_codeblock_command"; +import { + type CodeFormatter, + createEditor, + extractCodeBlockText, + flushMicrotasks, + setCodeFormatter, +} from "./format_codeblock_helpers"; + +describe("FormatCodeblockCommand", () => { + let domElement: HTMLDivElement; + let editor: ClassicEditor; + + beforeEach(async () => { + const result = await createEditor({ + isLanguageSupported: () => true, + format: async (code) => code, + }); + editor = result.editor; + domElement = result.div; + }); + + afterEach(() => { + domElement.remove(); + return editor.destroy(); + }); + + describe("refresh", () => { + it("should be enabled for a supported language", () => { + setModelData(editor.model, 'foo[]'); + + const command = editor.commands.get("formatCodeblock")!; + expect(command.isEnabled).toBe(true); + expect(command.value).toBe("javascript"); + }); + + it("should be disabled for an unsupported language", () => { + setCodeFormatter(editor, { + isLanguageSupported: () => false, + format: async (code) => code, + }); + setModelData(editor.model, 'foo[]'); + + const command = editor.commands.get("formatCodeblock")!; + expect(command.isEnabled).toBe(false); + expect(command.value).toBe(false); + }); + + it("should be disabled when selection is outside a code block", () => { + setModelData(editor.model, "foo[]"); + + const command = editor.commands.get("formatCodeblock")!; + expect(command.isEnabled).toBe(false); + expect(command.value).toBe(false); + }); + + it("should re-evaluate isLanguageSupported on every refresh", () => { + let supported = false; + setCodeFormatter(editor, { + isLanguageSupported: () => supported, + format: async (code) => code, + }); + setModelData(editor.model, 'foo[]'); + + const command = editor.commands.get("formatCodeblock")!; + expect(command.isEnabled).toBe(false); + + supported = true; + command.refresh(); + + expect(command.isEnabled).toBe(true); + }); + }); + + describe("execute", () => { + it("should preserve code block content when formatted output equals input", async () => { + const originalCode = "const answer = 42;"; + setModelData(editor.model, `${originalCode}[]`); + + const modelChangeSpy = vi.spyOn(editor.model, "change"); + editor.execute("formatCodeblock"); + + await flushMicrotasks(); + expect(modelChangeSpy).not.toHaveBeenCalled(); + expect(extractCodeBlockText(editor)).toBe(originalCode); + }); + + it("should replace code block content with the formatted result", async () => { + setCodeFormatter(editor, { + isLanguageSupported: () => true, + format: async (code) => `formatted:${code}`, + }); + setModelData(editor.model, 'original[]'); + editor.execute("formatCodeblock"); + + await vi.waitFor(() => { + expect(extractCodeBlockText(editor)).toBe("formatted:original"); + }, { timeout: 5000 }); + }); + + it("should insert softBreak elements between lines for multi-line output", async () => { + setCodeFormatter(editor, { + isLanguageSupported: () => true, + format: async () => "line1\nline2\nline3", + }); + setModelData(editor.model, 'anything[]'); + editor.execute("formatCodeblock"); + + await vi.waitFor(() => { + const root = editor.model.document.getRoot()!; + const codeBlock = Array.from(root.getChildren()).find((c) => c.is("element", "codeBlock")); + const children = Array.from(codeBlock!.getChildren()); + + expect(children).toHaveLength(5); + expect(children[0].is("$text")).toBe(true); + expect((children[0] as any).data).toBe("line1"); + expect(children[1].is("element", "softBreak")).toBe(true); + expect(children[2].is("$text")).toBe(true); + expect((children[2] as any).data).toBe("line2"); + expect(children[3].is("element", "softBreak")).toBe(true); + expect(children[4].is("$text")).toBe(true); + expect((children[4] as any).data).toBe("line3"); + }, { timeout: 5000 }); + }); + + it("should emit only softBreak (no text node) for empty lines", async () => { + setCodeFormatter(editor, { + isLanguageSupported: () => true, + format: async () => "a\n\nb", + }); + setModelData(editor.model, 'placeholder[]'); + editor.execute("formatCodeblock"); + + await vi.waitFor(() => { + const root = editor.model.document.getRoot()!; + const codeBlock = Array.from(root.getChildren()).find((c) => c.is("element", "codeBlock")); + const children = Array.from(codeBlock!.getChildren()); + + expect(children).toHaveLength(4); + expect(children[0].is("$text")).toBe(true); + expect((children[0] as any).data).toBe("a"); + expect(children[1].is("element", "softBreak")).toBe(true); + expect(children[2].is("element", "softBreak")).toBe(true); + expect(children[3].is("$text")).toBe(true); + expect((children[3] as any).data).toBe("b"); + }, { timeout: 5000 }); + }); + + it("should round-trip multi-line formatted code back to the original string", async () => { + const formattedCode = "const x = 1;\nconst y = 2;\nconst z = 3;"; + setCodeFormatter(editor, { + isLanguageSupported: () => true, + format: async () => formattedCode, + }); + setModelData(editor.model, 'unformatted[]'); + editor.execute("formatCodeblock"); + + await vi.waitFor(() => { + expect(extractCodeBlockText(editor)).toBe(formattedCode); + }, { timeout: 5000 }); + }); + + it("should not call model.change when selection is in a paragraph", () => { + setModelData(editor.model, "hello world[]"); + + const modelChangeSpy = vi.spyOn(editor.model, "change"); + (editor.commands.get("formatCodeblock")! as FormatCodeblockCommand).execute(); + + expect(modelChangeSpy).not.toHaveBeenCalled(); + }); + + it("should not call model.change when selection is in an unsupported-language code block", () => { + setCodeFormatter(editor, { + isLanguageSupported: () => false, + format: async (code) => code, + }); + setModelData(editor.model, 'code[]'); + + const modelChangeSpy = vi.spyOn(editor.model, "change"); + (editor.commands.get("formatCodeblock")! as FormatCodeblockCommand).execute(); + + expect(modelChangeSpy).not.toHaveBeenCalled(); + }); + + it("should not call format when code block contains only whitespace", async () => { + const formatSpy = vi.fn(async (code: string) => code); + setCodeFormatter(editor, { + isLanguageSupported: () => true, + format: formatSpy, + }); + setModelData(editor.model, ' []'); + editor.execute("formatCodeblock"); + + await flushMicrotasks(); + expect(formatSpy).not.toHaveBeenCalled(); + }); + + it("should not call format when code block is empty", async () => { + const formatSpy = vi.fn(async (code: string) => code); + setCodeFormatter(editor, { + isLanguageSupported: () => true, + format: formatSpy, + }); + setModelData(editor.model, '[]'); + editor.execute("formatCodeblock"); + + await flushMicrotasks(); + expect(formatSpy).not.toHaveBeenCalled(); + }); + }); + + describe("error handling", () => { + it("should show a warning notification when format throws", async () => { + const errorMessage = "Unexpected token on line 3"; + setCodeFormatter(editor, { + isLanguageSupported: () => true, + format: async () => { throw new Error(errorMessage); }, + }); + setModelData(editor.model, 'bad code[]'); + + const notification = editor.plugins.get(Notification); + const showWarningSpy = vi.spyOn(notification, "showWarning"); + notification.on("show:warning", (evt) => evt.stop(), { priority: "high" }); + + editor.execute("formatCodeblock"); + + await vi.waitFor(() => { + expect(showWarningSpy).toHaveBeenCalledOnce(); + }, { timeout: 5000 }); + + const [message, options] = showWarningSpy.mock.calls[0]; + expect(message).toBe(errorMessage); + expect(options?.namespace).toBe("formatCodeblock"); + }); + + it("should not modify the model when format rejects", async () => { + setCodeFormatter(editor, { + isLanguageSupported: () => true, + format: async () => { throw new Error("boom"); }, + }); + setModelData(editor.model, 'body { color red }[]'); + + const notification = editor.plugins.get(Notification); + const showWarningSpy = vi.spyOn(notification, "showWarning"); + notification.on("show:warning", (evt) => evt.stop(), { priority: "high" }); + + const modelChangeSpy = vi.spyOn(editor.model, "change"); + editor.execute("formatCodeblock"); + + await vi.waitFor(() => { + expect(showWarningSpy).toHaveBeenCalledOnce(); + }, { timeout: 5000 }); + + expect(modelChangeSpy).not.toHaveBeenCalled(); + }); + }); + + describe("without codeFormatter config", () => { + it("should always be disabled when no config is provided", async () => { + const { editor: noConfigEditor, div } = await createEditor(); + + setModelData(noConfigEditor.model, 'foo[]'); + expect(noConfigEditor.commands.get("formatCodeblock")!.isEnabled).toBe(false); + + await noConfigEditor.destroy(); + div.remove(); + }); + }); +}); diff --git a/packages/ckeditor5/tests/format_codeblock_helpers.ts b/packages/ckeditor5/tests/format_codeblock_helpers.ts new file mode 100644 index 00000000000..dc560c625e4 --- /dev/null +++ b/packages/ckeditor5/tests/format_codeblock_helpers.ts @@ -0,0 +1,48 @@ +import { ClassicEditor, CodeBlock, Paragraph } from "ckeditor5"; +import FormatCodeblockButton from "../src/plugins/format_codeblock/format_codeblock_button"; +import type { CodeFormatterConfig } from "../src/plugins/format_codeblock/types"; + +export type { CodeFormatterConfig as CodeFormatter }; + +export async function createEditor(codeFormatter?: CodeFormatterConfig) { + const div = document.createElement("div"); + document.body.appendChild(div); + + const editor = await ClassicEditor.create(div, { + licenseKey: "GPL", + plugins: [Paragraph, CodeBlock, FormatCodeblockButton], + ...(codeFormatter ? { codeFormatter } : {}), + codeBlock: { + languages: [ + { language: "javascript", label: "JavaScript" }, + { language: "python", label: "Python" }, + ], + }, + }); + + return { editor, div }; +} + +export function setCodeFormatter(editor: ClassicEditor, config: CodeFormatterConfig) { + editor.config.set("codeFormatter", config); + editor.commands.get("formatCodeblock")!.refresh(); +} + +export function extractCodeBlockText(editor: ClassicEditor): string { + const root = editor.model.document.getRoot()!; + const codeBlock = Array.from(root.getChildren()).find((c) => + c.is("element", "codeBlock"), + ); + return Array.from(codeBlock!.getChildren()) + .map((c) => ("data" in c ? c.data : "\n")) + .join(""); +} + +/** + * Two rounds of setTimeout(0) are needed because the source uses a + * .then().catch() chain — each leg enqueues a new microtask. + */ +export async function flushMicrotasks(): Promise { + await new Promise((r) => setTimeout(r, 0)); + await new Promise((r) => setTimeout(r, 0)); +} diff --git a/packages/ckeditor5/tests/templates.ts b/packages/ckeditor5/tests/templates.ts index f8894ac6090..dec74c48c64 100644 --- a/packages/ckeditor5/tests/templates.ts +++ b/packages/ckeditor5/tests/templates.ts @@ -2,7 +2,8 @@ import { it } from "vitest"; import { describe } from "vitest"; import { ClassicEditor } from "../src/index.js"; import { type BalloonEditor, type ButtonView, type Editor } from "ckeditor5"; -import { beforeEach } from "vitest"; +import { Template } from "ckeditor5-premium-features"; +import { beforeEach, afterEach } from "vitest"; import { expect } from "vitest"; describe("Text snippets", () => { @@ -13,10 +14,9 @@ describe("Text snippets", () => { editorElement = document.createElement( 'div' ); document.body.appendChild( editorElement ); - console.log("Trigger each"); - editor = await ClassicEditor.create(editorElement, { licenseKey: "GPL", + plugins: [ Template ], toolbar: { items: [ "insertTemplate" @@ -25,8 +25,13 @@ describe("Text snippets", () => { }); }); + afterEach(async () => { + await editor.destroy(); + editorElement.remove(); + }); + it("uses correct translations", () => { - const itemsWithButtonView = Array.from(editor.ui.view.toolbar?.items) + const itemsWithButtonView = Array.from(editor.ui.view.toolbar!.items) .filter(item => "buttonView" in item) .map(item => (item.buttonView as ButtonView).label); @@ -34,4 +39,3 @@ describe("Text snippets", () => { expect(itemsWithButtonView).toContain("Insert text snippet"); }); }); - diff --git a/packages/ckeditor5/vite.config.ts b/packages/ckeditor5/vite.config.ts index a310ff5c967..a47a091596a 100644 --- a/packages/ckeditor5/vite.config.ts +++ b/packages/ckeditor5/vite.config.ts @@ -1,6 +1,7 @@ /// import { defineConfig } from 'vite'; import dts from 'vite-plugin-dts'; +import { webdriverio } from "@vitest/browser-webdriverio"; import * as path from 'path'; export default defineConfig(() => ({ @@ -33,4 +34,19 @@ export default defineConfig(() => ({ } }, }, + test: { + browser: { + enabled: true, + provider: webdriverio(), + headless: true, + ui: false, + instances: [{ browser: 'chrome' }] + }, + include: [ + 'tests/**/*.[jt]s' + ], + globals: true, + watch: false, + passWithNoTests: true + }, })); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9e53f8e4168..a617a24326a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -353,6 +353,9 @@ importers: preact: specifier: 10.29.1 version: 10.29.1 + prettier: + specifier: ^3.5.3 + version: 3.8.1 react-i18next: specifier: 17.0.2 version: 17.0.2(i18next@26.0.3(typescript@6.0.2))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@6.0.2) @@ -960,6 +963,16 @@ importers: ckeditor5-premium-features: specifier: 48.0.0 version: 48.0.0(bufferutil@4.0.9)(ckeditor5@48.0.0)(utf-8-validate@6.0.5) + devDependencies: + '@smithy/middleware-retry': + specifier: 4.4.44 + version: 4.4.44 + '@types/jquery': + specifier: 4.0.0 + version: 4.0.0 + prettier: + specifier: ^3.5.3 + version: 3.8.1 packages/ckeditor5-admonition: devDependencies: @@ -5296,6 +5309,118 @@ packages: resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==} engines: {node: '>=18'} + '@smithy/core@3.23.14': + resolution: {integrity: sha512-vJ0IhpZxZAkFYOegMKSrxw7ujhhT2pass/1UEcZ4kfl5srTAqtPU5I7MdYQoreVas3204ykCiNhY1o7Xlz6Yyg==} + engines: {node: '>=18.0.0'} + + '@smithy/fetch-http-handler@5.3.16': + resolution: {integrity: sha512-nYDRUIvNd4mFmuXraRWt6w5UsZTNqtj4hXJA/iiOD4tuseIdLP9Lq38teH/SZTcIFCa2f+27o7hYpIsWktJKEQ==} + engines: {node: '>=18.0.0'} + + '@smithy/is-array-buffer@4.2.2': + resolution: {integrity: sha512-n6rQ4N8Jj4YTQO3YFrlgZuwKodf4zUFs7EJIWH86pSCWBaAtAGBFfCM7Wx6D2bBJ2xqFNxGBSrUWswT3M0VJow==} + engines: {node: '>=18.0.0'} + + '@smithy/middleware-endpoint@4.4.29': + resolution: {integrity: sha512-R9Q/58U+qBiSARGWbAbFLczECg/RmysRksX6Q8BaQEpt75I7LI6WGDZnjuC9GXSGKljEbA7N118LhGaMbfrTXw==} + engines: {node: '>=18.0.0'} + + '@smithy/middleware-retry@4.4.44': + resolution: {integrity: sha512-Y1Rav7m5CFRPQyM4CI0koD/bXjyjJu3EQxZZhtLGD88WIrBrQ7kqXM96ncd6rYnojwOo/u9MXu57JrEvu/nLrA==} + engines: {node: '>=18.0.0'} + + '@smithy/middleware-serde@4.2.17': + resolution: {integrity: sha512-0T2mcaM6v9W1xku86Dk0bEW7aEseG6KenFkPK98XNw0ZhOqOiD1MrMsdnQw9QsL3/Oa85T53iSMlm0SZdSuIEQ==} + engines: {node: '>=18.0.0'} + + '@smithy/middleware-stack@4.2.13': + resolution: {integrity: sha512-g72jN/sGDLyTanrCLH9fhg3oysO3f7tQa6eWWsMyn2BiYNCgjF24n4/I9wff/5XidFvjj9ilipAoQrurTUrLvw==} + engines: {node: '>=18.0.0'} + + '@smithy/node-config-provider@4.3.13': + resolution: {integrity: sha512-iGxQ04DsKXLckbgnX4ipElrOTk+IHgTyu0q0WssZfYhDm9CQWHmu6cOeI5wmWRxpXbBDhIIfXMWz5tPEtcVqbw==} + engines: {node: '>=18.0.0'} + + '@smithy/node-http-handler@4.5.2': + resolution: {integrity: sha512-/oD7u8M0oj2ZTFw7GkuuHWpIxtWdLlnyNkbrWcyVYhd5RJNDuczdkb0wfnQICyNFrVPlr8YHOhamjNy3zidhmA==} + engines: {node: '>=18.0.0'} + + '@smithy/property-provider@4.2.13': + resolution: {integrity: sha512-bGzUCthxRmezuxkbu9wD33wWg9KX3hJpCXpQ93vVkPrHn9ZW6KNNdY5xAUWNuRCwQ+VyboFuWirG1lZhhkcyRQ==} + engines: {node: '>=18.0.0'} + + '@smithy/protocol-http@5.3.13': + resolution: {integrity: sha512-+HsmuJUF4u8POo6s8/a2Yb/AQ5t/YgLovCuHF9oxbocqv+SZ6gd8lC2duBFiCA/vFHoHQhoq7QjqJqZC6xOxxg==} + engines: {node: '>=18.0.0'} + + '@smithy/querystring-builder@4.2.13': + resolution: {integrity: sha512-tG4aOYFCZdPMjbgfhnIQ322H//ojujldp1SrHPHpBSb3NqgUp3dwiUGRJzie87hS1DYwWGqDuPaowoDF+rYCbQ==} + engines: {node: '>=18.0.0'} + + '@smithy/querystring-parser@4.2.13': + resolution: {integrity: sha512-hqW3Q4P+CDzUyQ87GrboGMeD7XYNMOF+CuTwu936UQRB/zeYn3jys8C3w+wMkDfY7CyyyVwZQ5cNFoG0x1pYmA==} + engines: {node: '>=18.0.0'} + + '@smithy/service-error-classification@4.2.13': + resolution: {integrity: sha512-a0s8XZMfOC/qpqq7RCPvJlk93rWFrElH6O++8WJKz0FqnA4Y7fkNi/0mnGgSH1C4x6MFsuBA8VKu4zxFrMe5Vw==} + engines: {node: '>=18.0.0'} + + '@smithy/shared-ini-file-loader@4.4.8': + resolution: {integrity: sha512-VZCZx2bZasxdqxVgEAhREvDSlkatTPnkdWy1+Kiy8w7kYPBosW0V5IeDwzDUMvWBt56zpK658rx1cOBFOYaPaw==} + engines: {node: '>=18.0.0'} + + '@smithy/smithy-client@4.12.9': + resolution: {integrity: sha512-ovaLEcTU5olSeHcRXcxV6viaKtpkHZumn6Ps0yn7dRf2rRSfy794vpjOtrWDO0d1auDSvAqxO+lyhERSXQ03EQ==} + engines: {node: '>=18.0.0'} + + '@smithy/types@4.14.0': + resolution: {integrity: sha512-OWgntFLW88kx2qvf/c/67Vno1yuXm/f9M7QFAtVkkO29IJXGBIg0ycEaBTH0kvCtwmvZxRujrgP5a86RvsXJAQ==} + engines: {node: '>=18.0.0'} + + '@smithy/url-parser@4.2.13': + resolution: {integrity: sha512-2G03yoboIRZlZze2+PT4GZEjgwQsJjUgn6iTsvxA02bVceHR6vp4Cuk7TUnPFWKF+ffNUk3kj4COwkENS2K3vw==} + engines: {node: '>=18.0.0'} + + '@smithy/util-base64@4.3.2': + resolution: {integrity: sha512-XRH6b0H/5A3SgblmMa5ErXQ2XKhfbQB+Fm/oyLZ2O2kCUrwgg55bU0RekmzAhuwOjA9qdN5VU2BprOvGGUkOOQ==} + engines: {node: '>=18.0.0'} + + '@smithy/util-body-length-browser@4.2.2': + resolution: {integrity: sha512-JKCrLNOup3OOgmzeaKQwi4ZCTWlYR5H4Gm1r2uTMVBXoemo1UEghk5vtMi1xSu2ymgKVGW631e2fp9/R610ZjQ==} + engines: {node: '>=18.0.0'} + + '@smithy/util-buffer-from@4.2.2': + resolution: {integrity: sha512-FDXD7cvUoFWwN6vtQfEta540Y/YBe5JneK3SoZg9bThSoOAC/eGeYEua6RkBgKjGa/sz6Y+DuBZj3+YEY21y4Q==} + engines: {node: '>=18.0.0'} + + '@smithy/util-hex-encoding@4.2.2': + resolution: {integrity: sha512-Qcz3W5vuHK4sLQdyT93k/rfrUwdJ8/HZ+nMUOyGdpeGA1Wxt65zYwi3oEl9kOM+RswvYq90fzkNDahPS8K0OIg==} + engines: {node: '>=18.0.0'} + + '@smithy/util-middleware@4.2.13': + resolution: {integrity: sha512-GTooyrlmRTqvUen4eK7/K1p6kryF7bnDfq6XsAbIsf2mo51B/utaH+XThY6dKgNCWzMAaH/+OLmqaBuLhLWRow==} + engines: {node: '>=18.0.0'} + + '@smithy/util-retry@4.3.0': + resolution: {integrity: sha512-tSOPQNT/4KfbvqeMovWC3g23KSYy8czHd3tlN+tOYVNIDLSfxIsrPJihYi5TpNcoV789KWtgChUVedh2y6dDPg==} + engines: {node: '>=18.0.0'} + + '@smithy/util-stream@4.5.22': + resolution: {integrity: sha512-3H8iq/0BfQjUs2/4fbHZ9aG9yNzcuZs24LPkcX1Q7Z+qpqaGM8+qbGmE8zo9m2nCRgamyvS98cHdcWvR6YUsew==} + engines: {node: '>=18.0.0'} + + '@smithy/util-uri-escape@4.2.2': + resolution: {integrity: sha512-2kAStBlvq+lTXHyAZYfJRb/DfS3rsinLiwb+69SstC9Vb0s9vNWkRwpnj918Pfi85mzi42sOqdV72OLxWAISnw==} + engines: {node: '>=18.0.0'} + + '@smithy/util-utf8@4.2.2': + resolution: {integrity: sha512-75MeYpjdWRe8M5E3AW0O4Cx3UadweS+cwdXjwYGBW5h/gxxnbeZ877sLPX/ZJA9GVTlL/qG0dXP29JWFCD1Ayw==} + engines: {node: '>=18.0.0'} + + '@smithy/uuid@1.1.2': + resolution: {integrity: sha512-O/IEdcCUKkubz60tFbGA7ceITTAJsty+lBjNoorP4Z6XRqaFb/OjQjZODophEcuq68nKm6/0r+6/lLQ+XVpk8g==} + engines: {node: '>=18.0.0'} + '@socket.io/component-emitter@3.1.2': resolution: {integrity: sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==} @@ -7073,6 +7198,7 @@ packages: basic-ftp@5.2.0: resolution: {integrity: sha512-VoMINM2rqJwJgfdHq6RiUudKt2BV+FY5ZFezP/ypmwayk68+NzzAQy4XXLlqsGD4MCzq3DrmNFD/uUmBJuGoXw==} engines: {node: '>=10.0.0'} + deprecated: Security vulnerability fixed in 5.2.1, please upgrade better-ajv-errors@1.2.0: resolution: {integrity: sha512-UW+IsFycygIo7bclP9h5ugkNH8EjCSgqyFB/yQ4Hqqa1OEYDtb0uFIkYE0b6+CjkgJYVM5UKI/pJPxjYe9EZlA==} @@ -11689,6 +11815,11 @@ packages: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} + prettier@3.8.1: + resolution: {integrity: sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==} + engines: {node: '>=14'} + hasBin: true + prismjs@1.30.0: resolution: {integrity: sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==} engines: {node: '>=6'} @@ -18925,6 +19056,184 @@ snapshots: '@sindresorhus/merge-streams@4.0.0': {} + '@smithy/core@3.23.14': + dependencies: + '@smithy/protocol-http': 5.3.13 + '@smithy/types': 4.14.0 + '@smithy/url-parser': 4.2.13 + '@smithy/util-base64': 4.3.2 + '@smithy/util-body-length-browser': 4.2.2 + '@smithy/util-middleware': 4.2.13 + '@smithy/util-stream': 4.5.22 + '@smithy/util-utf8': 4.2.2 + '@smithy/uuid': 1.1.2 + tslib: 2.8.1 + + '@smithy/fetch-http-handler@5.3.16': + dependencies: + '@smithy/protocol-http': 5.3.13 + '@smithy/querystring-builder': 4.2.13 + '@smithy/types': 4.14.0 + '@smithy/util-base64': 4.3.2 + tslib: 2.8.1 + + '@smithy/is-array-buffer@4.2.2': + dependencies: + tslib: 2.8.1 + + '@smithy/middleware-endpoint@4.4.29': + dependencies: + '@smithy/core': 3.23.14 + '@smithy/middleware-serde': 4.2.17 + '@smithy/node-config-provider': 4.3.13 + '@smithy/shared-ini-file-loader': 4.4.8 + '@smithy/types': 4.14.0 + '@smithy/url-parser': 4.2.13 + '@smithy/util-middleware': 4.2.13 + tslib: 2.8.1 + + '@smithy/middleware-retry@4.4.44': + dependencies: + '@smithy/node-config-provider': 4.3.13 + '@smithy/protocol-http': 5.3.13 + '@smithy/service-error-classification': 4.2.13 + '@smithy/smithy-client': 4.12.9 + '@smithy/types': 4.14.0 + '@smithy/util-middleware': 4.2.13 + '@smithy/util-retry': 4.3.0 + '@smithy/uuid': 1.1.2 + tslib: 2.8.1 + + '@smithy/middleware-serde@4.2.17': + dependencies: + '@smithy/core': 3.23.14 + '@smithy/protocol-http': 5.3.13 + '@smithy/types': 4.14.0 + tslib: 2.8.1 + + '@smithy/middleware-stack@4.2.13': + dependencies: + '@smithy/types': 4.14.0 + tslib: 2.8.1 + + '@smithy/node-config-provider@4.3.13': + dependencies: + '@smithy/property-provider': 4.2.13 + '@smithy/shared-ini-file-loader': 4.4.8 + '@smithy/types': 4.14.0 + tslib: 2.8.1 + + '@smithy/node-http-handler@4.5.2': + dependencies: + '@smithy/protocol-http': 5.3.13 + '@smithy/querystring-builder': 4.2.13 + '@smithy/types': 4.14.0 + tslib: 2.8.1 + + '@smithy/property-provider@4.2.13': + dependencies: + '@smithy/types': 4.14.0 + tslib: 2.8.1 + + '@smithy/protocol-http@5.3.13': + dependencies: + '@smithy/types': 4.14.0 + tslib: 2.8.1 + + '@smithy/querystring-builder@4.2.13': + dependencies: + '@smithy/types': 4.14.0 + '@smithy/util-uri-escape': 4.2.2 + tslib: 2.8.1 + + '@smithy/querystring-parser@4.2.13': + dependencies: + '@smithy/types': 4.14.0 + tslib: 2.8.1 + + '@smithy/service-error-classification@4.2.13': + dependencies: + '@smithy/types': 4.14.0 + + '@smithy/shared-ini-file-loader@4.4.8': + dependencies: + '@smithy/types': 4.14.0 + tslib: 2.8.1 + + '@smithy/smithy-client@4.12.9': + dependencies: + '@smithy/core': 3.23.14 + '@smithy/middleware-endpoint': 4.4.29 + '@smithy/middleware-stack': 4.2.13 + '@smithy/protocol-http': 5.3.13 + '@smithy/types': 4.14.0 + '@smithy/util-stream': 4.5.22 + tslib: 2.8.1 + + '@smithy/types@4.14.0': + dependencies: + tslib: 2.8.1 + + '@smithy/url-parser@4.2.13': + dependencies: + '@smithy/querystring-parser': 4.2.13 + '@smithy/types': 4.14.0 + tslib: 2.8.1 + + '@smithy/util-base64@4.3.2': + dependencies: + '@smithy/util-buffer-from': 4.2.2 + '@smithy/util-utf8': 4.2.2 + tslib: 2.8.1 + + '@smithy/util-body-length-browser@4.2.2': + dependencies: + tslib: 2.8.1 + + '@smithy/util-buffer-from@4.2.2': + dependencies: + '@smithy/is-array-buffer': 4.2.2 + tslib: 2.8.1 + + '@smithy/util-hex-encoding@4.2.2': + dependencies: + tslib: 2.8.1 + + '@smithy/util-middleware@4.2.13': + dependencies: + '@smithy/types': 4.14.0 + tslib: 2.8.1 + + '@smithy/util-retry@4.3.0': + dependencies: + '@smithy/service-error-classification': 4.2.13 + '@smithy/types': 4.14.0 + tslib: 2.8.1 + + '@smithy/util-stream@4.5.22': + dependencies: + '@smithy/fetch-http-handler': 5.3.16 + '@smithy/node-http-handler': 4.5.2 + '@smithy/types': 4.14.0 + '@smithy/util-base64': 4.3.2 + '@smithy/util-buffer-from': 4.2.2 + '@smithy/util-hex-encoding': 4.2.2 + '@smithy/util-utf8': 4.2.2 + tslib: 2.8.1 + + '@smithy/util-uri-escape@4.2.2': + dependencies: + tslib: 2.8.1 + + '@smithy/util-utf8@4.2.2': + dependencies: + '@smithy/util-buffer-from': 4.2.2 + tslib: 2.8.1 + + '@smithy/uuid@1.1.2': + dependencies: + tslib: 2.8.1 + '@socket.io/component-emitter@3.1.2': {} '@ssddanbrown/codemirror-lang-smarty@1.0.0': {} @@ -27489,6 +27798,8 @@ snapshots: prelude-ls@1.2.1: {} + prettier@3.8.1: {} + prismjs@1.30.0: {} proc-log@2.0.1: {}