Skip to content

Commit 16fe432

Browse files
refactor(ckeditor5): move actual code formatting logic in the client instead of a ckeditor5 plugin
1 parent 1dcb400 commit 16fe432

File tree

11 files changed

+461
-372
lines changed

11 files changed

+461
-372
lines changed

apps/client/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@
7474
"rrule": "2.8.1",
7575
"svg-pan-zoom": "3.6.2",
7676
"tabulator-tables": "6.4.0",
77+
"prettier": "^3.5.3",
7778
"vanilla-js-wheel-zoom": "9.0.4"
7879
},
7980
"devDependencies": {
Lines changed: 246 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,246 @@
1+
import { type CodeFormatter, FormatterRegistry } from "./code_formatter.js";
2+
import { beforeEach, describe, expect, it } from "vitest";
3+
4+
function makeFormatter(
5+
name: string,
6+
supportedLanguages: string[],
7+
): CodeFormatter {
8+
return {
9+
name,
10+
canFormat(language: string): boolean {
11+
return supportedLanguages.includes(language);
12+
},
13+
async format(code: string, _language: string): Promise<string> {
14+
return `[${name}] ${code}`;
15+
},
16+
};
17+
}
18+
19+
function makeNeverFormatter(name = "NeverFormatter"): CodeFormatter {
20+
return makeFormatter(name, []);
21+
}
22+
23+
describe("FormatterRegistry", () => {
24+
let registry: FormatterRegistry;
25+
26+
beforeEach(() => {
27+
registry = new FormatterRegistry();
28+
});
29+
30+
describe("initial state", () => {
31+
it("should have no formatters registered", () => {
32+
expect(registry.isLanguageSupported("javascript")).toBe(false);
33+
});
34+
35+
it("should return undefined for getFormatterForLanguage when empty", () => {
36+
expect(
37+
registry.getFormatterForLanguage("typescript"),
38+
).toBeUndefined();
39+
});
40+
});
41+
42+
describe("isLanguageSupported", () => {
43+
it("should return false for an unknown language when formatters are registered", () => {
44+
registry.register(makeFormatter("A", ["javascript"]));
45+
46+
expect(registry.isLanguageSupported("python")).toBe(false);
47+
});
48+
49+
it("should return true for a language handled by a registered formatter", () => {
50+
registry.register(makeFormatter("A", ["javascript"]));
51+
52+
expect(registry.isLanguageSupported("javascript")).toBe(true);
53+
});
54+
55+
it("should return true when only one of many formatters handles the language", () => {
56+
registry.register(makeFormatter("A", ["css"]));
57+
registry.register(makeFormatter("B", ["html"]));
58+
59+
expect(registry.isLanguageSupported("html")).toBe(true);
60+
});
61+
62+
it("should return false when all registered formatters reject the language", () => {
63+
registry.register(makeNeverFormatter("X"));
64+
registry.register(makeNeverFormatter("Y"));
65+
66+
expect(registry.isLanguageSupported("rust")).toBe(false);
67+
});
68+
});
69+
70+
describe("getFormatterForLanguage", () => {
71+
it("should return undefined for an unregistered language", () => {
72+
registry.register(makeFormatter("A", ["javascript"]));
73+
74+
expect(registry.getFormatterForLanguage("rust")).toBeUndefined();
75+
});
76+
77+
it("should return the matching formatter for a registered language", () => {
78+
const formatter = makeFormatter("Prettier", ["typescript"]);
79+
registry.register(formatter);
80+
81+
expect(registry.getFormatterForLanguage("typescript")).toBe(
82+
formatter,
83+
);
84+
});
85+
86+
it("should return the first matching formatter when multiple formatters support the language", () => {
87+
const first = makeFormatter("First", ["javascript"]);
88+
const second = makeFormatter("Second", ["javascript"]);
89+
registry.register(first);
90+
registry.register(second);
91+
92+
expect(registry.getFormatterForLanguage("javascript")).toBe(first);
93+
});
94+
95+
it("should return the correct formatter when languages do not overlap", () => {
96+
const cssFormatter = makeFormatter("CSS", ["css"]);
97+
const jsFormatter = makeFormatter("JS", ["javascript"]);
98+
registry.register(cssFormatter);
99+
registry.register(jsFormatter);
100+
101+
expect(registry.getFormatterForLanguage("css")).toBe(cssFormatter);
102+
expect(registry.getFormatterForLanguage("javascript")).toBe(
103+
jsFormatter,
104+
);
105+
});
106+
});
107+
108+
describe("register", () => {
109+
it("should allow registering a single formatter", () => {
110+
registry.register(makeFormatter("A", ["json"]));
111+
112+
expect(registry.isLanguageSupported("json")).toBe(true);
113+
});
114+
115+
it("should allow registering multiple formatters independently", () => {
116+
registry.register(makeFormatter("A", ["json"]));
117+
registry.register(makeFormatter("B", ["yaml"]));
118+
119+
expect(registry.isLanguageSupported("json")).toBe(true);
120+
expect(registry.isLanguageSupported("yaml")).toBe(true);
121+
});
122+
123+
it("should give priority to the first registered formatter when both handle the same language", () => {
124+
const first = makeFormatter("First", ["scss"]);
125+
const second = makeFormatter("Second", ["scss"]);
126+
registry.register(first);
127+
registry.register(second);
128+
129+
const resolved = registry.getFormatterForLanguage("scss");
130+
131+
expect(resolved?.name).toBe("First");
132+
});
133+
134+
it("should skip non-matching formatters and reach the one that matches", () => {
135+
const noMatch = makeNeverFormatter("NoMatch");
136+
const match = makeFormatter("Match", ["graphql"]);
137+
registry.register(noMatch);
138+
registry.register(match);
139+
140+
expect(registry.getFormatterForLanguage("graphql")).toBe(match);
141+
});
142+
});
143+
144+
describe("canFormat delegation", () => {
145+
it("should delegate canFormat to each registered formatter in order", () => {
146+
const callLog: string[] = [];
147+
148+
const trackingFormatter = (
149+
name: string,
150+
languages: string[],
151+
): CodeFormatter => ({
152+
name,
153+
canFormat(language: string): boolean {
154+
callLog.push(name);
155+
return languages.includes(language);
156+
},
157+
async format(code: string): Promise<string> {
158+
return code;
159+
},
160+
});
161+
162+
const formatterA = trackingFormatter("A", []);
163+
const formatterB = trackingFormatter("B", ["markdown"]);
164+
registry.register(formatterA);
165+
registry.register(formatterB);
166+
167+
registry.getFormatterForLanguage("markdown");
168+
169+
expect(callLog).toEqual(["A", "B"]);
170+
});
171+
172+
it("should stop delegation at the first formatter that handles the language", () => {
173+
const callLog: string[] = [];
174+
175+
const trackingFormatter = (
176+
name: string,
177+
languages: string[],
178+
): CodeFormatter => ({
179+
name,
180+
canFormat(language: string): boolean {
181+
callLog.push(name);
182+
return languages.includes(language);
183+
},
184+
async format(code: string): Promise<string> {
185+
return code;
186+
},
187+
});
188+
189+
const formatterA = trackingFormatter("A", ["html"]);
190+
const formatterB = trackingFormatter("B", ["html"]);
191+
registry.register(formatterA);
192+
registry.register(formatterB);
193+
194+
registry.getFormatterForLanguage("html");
195+
196+
// Array.prototype.find stops at the first truthy result, so B
197+
// should never be consulted.
198+
expect(callLog).toEqual(["A"]);
199+
});
200+
});
201+
202+
describe("CodeFormatter interface contract", () => {
203+
it("should expose a readonly name property", () => {
204+
const formatter = makeFormatter("TestFormatter", ["javascript"]);
205+
206+
expect(formatter.name).toBe("TestFormatter");
207+
});
208+
209+
it("canFormat should return true for a supported language", () => {
210+
const formatter = makeFormatter("F", ["css", "scss"]);
211+
212+
expect(formatter.canFormat("css")).toBe(true);
213+
expect(formatter.canFormat("scss")).toBe(true);
214+
});
215+
216+
it("canFormat should return false for an unsupported language", () => {
217+
const formatter = makeFormatter("F", ["css"]);
218+
219+
expect(formatter.canFormat("rust")).toBe(false);
220+
});
221+
222+
it("format should return a promise that resolves to a string", async () => {
223+
const formatter = makeFormatter("F", ["javascript"]);
224+
225+
const result = await formatter.format("const x = 1", "javascript");
226+
227+
expect(typeof result).toBe("string");
228+
});
229+
230+
it("format should resolve with the formatted output", async () => {
231+
const formatter = makeFormatter("F", ["javascript"]);
232+
233+
const result = await formatter.format("const x = 1", "javascript");
234+
235+
expect(result).toBe("[F] const x = 1");
236+
});
237+
238+
it("format should preserve empty string input", async () => {
239+
const formatter = makeFormatter("F", ["javascript"]);
240+
241+
const result = await formatter.format("", "javascript");
242+
243+
expect(result).toBe("[F] ");
244+
});
245+
});
246+
});

packages/ckeditor5/src/plugins/format_codeblock/code_formatter.ts renamed to apps/client/src/services/code_formatter.ts

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,16 +5,8 @@ export interface CodeFormatter {
55
}
66

77
export class FormatterRegistry {
8-
private static instance: FormatterRegistry | null = null;
98
private readonly formatters: CodeFormatter[] = [];
109

11-
static getInstance(): FormatterRegistry {
12-
if (!FormatterRegistry.instance) {
13-
FormatterRegistry.instance = new FormatterRegistry();
14-
}
15-
return FormatterRegistry.instance;
16-
}
17-
1810
register(formatter: CodeFormatter): void {
1911
this.formatters.push(formatter);
2012
}

packages/ckeditor5/src/plugins/format_codeblock/prettier_formatter.ts renamed to apps/client/src/services/prettier_formatter.ts

Lines changed: 15 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,9 @@
1-
import type { CodeFormatter } from "./code_formatter";
2-
import {
3-
LANG_JAVASCRIPT_FRONTEND,
4-
LANG_JAVASCRIPT_BACKEND,
5-
LANG_TYPESCRIPT,
6-
LANG_TYPESCRIPT_JSX,
7-
LANG_JSX,
8-
LANG_JSON,
9-
LANG_CSS,
10-
LANG_LESS,
11-
LANG_SCSS,
12-
LANG_HTML,
13-
LANG_YAML,
14-
LANG_MARKDOWN,
15-
LANG_GRAPHQL,
16-
} from "./languages";
1+
import type { CodeFormatter } from "./code_formatter.js";
172
import type { Plugin } from "prettier";
183

194
interface PrettierParserConfig {
205
parser: string;
21-
plugins: () => Promise<(string | URL | Plugin<any>)[]>;
6+
plugins: () => Promise<(string | URL | Plugin)[]>;
227
}
238

249
const babelPlugins = () =>
@@ -37,28 +22,28 @@ const postcssPlugins = () =>
3722
import("prettier/plugins/postcss").then((m) => [m]);
3823

3924
const LANGUAGE_MAP: Record<string, PrettierParserConfig> = {
40-
[LANG_JAVASCRIPT_FRONTEND]: { parser: "babel", plugins: babelPlugins },
41-
[LANG_JAVASCRIPT_BACKEND]: { parser: "babel", plugins: babelPlugins },
42-
[LANG_JSX]: { parser: "babel", plugins: babelPlugins },
43-
[LANG_TYPESCRIPT]: { parser: "typescript", plugins: typescriptPlugins },
44-
[LANG_TYPESCRIPT_JSX]: { parser: "typescript", plugins: typescriptPlugins },
45-
[LANG_JSON]: { parser: "json", plugins: babelPlugins },
46-
[LANG_CSS]: { parser: "css", plugins: postcssPlugins },
47-
[LANG_LESS]: { parser: "less", plugins: postcssPlugins },
48-
[LANG_SCSS]: { parser: "scss", plugins: postcssPlugins },
49-
[LANG_HTML]: {
25+
"application-javascript-env-frontend": { parser: "babel", plugins: babelPlugins },
26+
"application-javascript-env-backend": { parser: "babel", plugins: babelPlugins },
27+
"text-jsx": { parser: "babel", plugins: babelPlugins },
28+
"application-typescript": { parser: "typescript", plugins: typescriptPlugins },
29+
"text-typescript-jsx": { parser: "typescript", plugins: typescriptPlugins },
30+
"application-json": { parser: "json", plugins: babelPlugins },
31+
"text-css": { parser: "css", plugins: postcssPlugins },
32+
"text-x-less": { parser: "less", plugins: postcssPlugins },
33+
"text-x-scss": { parser: "scss", plugins: postcssPlugins },
34+
"text-html": {
5035
parser: "html",
5136
plugins: () => import("prettier/plugins/html").then((m) => [m]),
5237
},
53-
[LANG_YAML]: {
38+
"text-x-yaml": {
5439
parser: "yaml",
5540
plugins: () => import("prettier/plugins/yaml").then((m) => [m]),
5641
},
57-
[LANG_MARKDOWN]: {
42+
"text-x-markdown": {
5843
parser: "markdown",
5944
plugins: () => import("prettier/plugins/markdown").then((m) => [m]),
6045
},
61-
[LANG_GRAPHQL]: {
46+
"text-x-graphql": {
6247
parser: "graphql",
6348
plugins: () => import("prettier/plugins/graphql").then((m) => [m]),
6449
},

apps/client/src/widgets/type_widgets/text/config.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { buildExtraCommands, type EditorConfig, getCkLocale, loadPremiumPlugins, TemplateDefinition } from "@triliumnext/ckeditor5";
1+
import { buildExtraCommands, type EditorConfig, type FormatterRegistryInterface, getCkLocale, loadPremiumPlugins, TemplateDefinition } from "@triliumnext/ckeditor5";
22
import emojiDefinitionsUrl from "@triliumnext/ckeditor5/src/emoji_definitions/en.json?url";
33
import { ALLOWED_PROTOCOLS, DISPLAYABLE_LOCALE_IDS, MIME_TYPE_AUTO, normalizeMimeTypeForCKEditor } from "@triliumnext/commons";
44

@@ -9,6 +9,8 @@ import { default as mimeTypesService, getHighlightJsNameForMime } from "../../..
99
import noteAutocompleteService, { type Suggestion } from "../../../services/note_autocomplete.js";
1010
import options from "../../../services/options.js";
1111
import { ensureMimeTypesForHighlighting, isSyntaxHighlightEnabled } from "../../../services/syntax_highlight.js";
12+
import { FormatterRegistry } from "../../../services/code_formatter.js";
13+
import { PrettierFormatter } from "../../../services/prettier_formatter.js";
1214
import { buildToolbarConfig } from "./toolbar.js";
1315

1416
export const OPEN_SOURCE_LICENSE_KEY = "GPL";
@@ -28,6 +30,9 @@ export async function buildConfig(opts: BuildEditorOptions): Promise<EditorConfi
2830
const config: EditorConfig = {
2931
licenseKey,
3032
placeholder: t("editable_text.placeholder"),
33+
codeFormatter: {
34+
registry: buildFormatterRegistry(),
35+
},
3136
codeBlock: {
3237
languages: buildListOfLanguages()
3338
},
@@ -223,6 +228,12 @@ function buildListOfLanguages() {
223228
];
224229
}
225230

231+
function buildFormatterRegistry(): FormatterRegistryInterface {
232+
const registry = new FormatterRegistry();
233+
registry.register(new PrettierFormatter());
234+
return registry;
235+
}
236+
226237
function getLicenseKey() {
227238
const premiumLicenseKey = import.meta.env.VITE_CKEDITOR_KEY;
228239
if (!premiumLicenseKey) {
@@ -246,3 +257,4 @@ function getDisabledPlugins() {
246257

247258
return disabledPlugins;
248259
}
260+

packages/ckeditor5/package.json

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,11 @@
1616
"@triliumnext/ckeditor5-math": "workspace:*",
1717
"@triliumnext/ckeditor5-mermaid": "workspace:*",
1818
"ckeditor5": "47.6.1",
19-
"ckeditor5-premium-features": "47.6.1",
20-
"prettier": "^3.5.3"
19+
"ckeditor5-premium-features": "47.6.1"
2120
},
2221
"devDependencies": {
2322
"@smithy/middleware-retry": "4.4.43",
24-
"@types/jquery": "4.0.0"
23+
"@types/jquery": "4.0.0",
24+
"prettier": "^3.5.3"
2525
}
2626
}

0 commit comments

Comments
 (0)