Skip to content

Commit 3459744

Browse files
committed
feat: add prettier formatter
1 parent 5392304 commit 3459744

File tree

3 files changed

+367
-0
lines changed

3 files changed

+367
-0
lines changed

src/lib/prettierFormatter.js

Lines changed: 363 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,363 @@
1+
import fsOperation from "fileSystem";
2+
import toast from "components/toast";
3+
import appSettings from "lib/settings";
4+
import prettierPluginBabel from "prettier/plugins/babel";
5+
import prettierPluginGraphql from "prettier/plugins/graphql";
6+
import prettierPluginHtml from "prettier/plugins/html";
7+
import prettierPluginMarkdown from "prettier/plugins/markdown";
8+
import prettierPluginPostcss from "prettier/plugins/postcss";
9+
import prettierPluginTypescript from "prettier/plugins/typescript";
10+
import prettierPluginYaml from "prettier/plugins/yaml";
11+
import prettier from "prettier/standalone";
12+
import helpers from "utils/helpers";
13+
import Url from "utils/Url";
14+
15+
const PRETTIER_ID = "prettier";
16+
const PRETTIER_NAME = "Prettier";
17+
const CONFIG_FILENAMES = [
18+
".prettierrc",
19+
".prettierrc.json",
20+
".prettierrc.json5",
21+
".prettierrc.js",
22+
".prettierrc.cjs",
23+
".prettierrc.mjs",
24+
".prettierrc.config.cjs",
25+
".prettierrc.config.mjs",
26+
".prettier.config.js",
27+
".prettier.config.cjs",
28+
".prettier.config.mjs",
29+
"prettier.config.json",
30+
"prettier.config.js",
31+
"prettier.config.cjs",
32+
"prettier.config.mjs",
33+
];
34+
const PRETTIER_PLUGINS = [
35+
prettierPluginBabel,
36+
prettierPluginHtml,
37+
prettierPluginMarkdown,
38+
prettierPluginPostcss,
39+
prettierPluginTypescript,
40+
prettierPluginYaml,
41+
prettierPluginGraphql,
42+
];
43+
44+
/**
45+
* Supported parser mapping keyed by CodeMirror mode name
46+
* @type {Record<string, string>}
47+
*/
48+
const MODE_TO_PARSER = {
49+
angular: "angular",
50+
gfm: "markdown",
51+
css: "css",
52+
graphql: "graphql",
53+
html: "html",
54+
json: "json",
55+
json5: "json",
56+
jsx: "babel",
57+
less: "less",
58+
markdown: "markdown",
59+
md: "markdown",
60+
mdx: "mdx",
61+
scss: "scss",
62+
styled_jsx: "babel",
63+
typescript: "typescript",
64+
tsx: "typescript",
65+
jsonc: "json",
66+
yaml: "yaml",
67+
yml: "yaml",
68+
vue: "vue",
69+
javascript: "babel",
70+
};
71+
72+
const SUPPORTED_EXTENSIONS = [
73+
"js",
74+
"cjs",
75+
"mjs",
76+
"jsx",
77+
"ts",
78+
"tsx",
79+
"json",
80+
"json5",
81+
"css",
82+
"scss",
83+
"less",
84+
"html",
85+
"htm",
86+
"vue",
87+
"md",
88+
"markdown",
89+
"mdx",
90+
"yaml",
91+
"yml",
92+
"graphql",
93+
"gql",
94+
];
95+
96+
/**
97+
* Register Prettier formatter with Acode instance
98+
*/
99+
export function registerPrettierFormatter() {
100+
if (!window?.acode) return;
101+
const alreadyRegistered = acode.formatters.some(
102+
({ id }) => id === PRETTIER_ID,
103+
);
104+
if (alreadyRegistered) return;
105+
acode.registerFormatter(
106+
PRETTIER_ID,
107+
SUPPORTED_EXTENSIONS,
108+
() => formatActiveFileWithPrettier(),
109+
PRETTIER_NAME,
110+
);
111+
}
112+
113+
async function formatActiveFileWithPrettier() {
114+
const file = editorManager?.activeFile;
115+
const editor = editorManager?.editor;
116+
if (!file || file.type !== "editor" || !editor) return false;
117+
118+
const modeName = (file.currentMode || "text").toLowerCase();
119+
const parser = getParserForMode(modeName);
120+
if (!parser) {
121+
toast("Prettier does not support this file type yet");
122+
return false;
123+
}
124+
125+
const doc = editor.state.doc;
126+
const source = doc.toString();
127+
const filepath = file.uri || file.filename || "";
128+
try {
129+
const config = await resolvePrettierConfig(file);
130+
const formatted = await prettier.format(source, {
131+
...config,
132+
parser,
133+
plugins: PRETTIER_PLUGINS,
134+
filepath,
135+
overrideEditorconfig: true,
136+
});
137+
138+
if (formatted === source) return true;
139+
140+
editor.dispatch({
141+
changes: {
142+
from: 0,
143+
to: doc.length,
144+
insert: formatted,
145+
},
146+
});
147+
return true;
148+
} catch (error) {
149+
const message = error instanceof Error ? error.message : String(error);
150+
toast(message);
151+
return false;
152+
}
153+
}
154+
155+
function getParserForMode(modeName) {
156+
if (MODE_TO_PARSER[modeName]) return MODE_TO_PARSER[modeName];
157+
if (modeName.includes("javascript")) return "babel";
158+
if (modeName.includes("typescript")) return "typescript";
159+
return null;
160+
}
161+
162+
async function resolvePrettierConfig(file) {
163+
const overrides = appSettings?.value?.prettier || {};
164+
const projectConfig = await loadProjectConfig(file);
165+
const result = { ...overrides, ...(projectConfig || {}) };
166+
if (file?.eol && result.endOfLine == null) {
167+
result.endOfLine = file.eol === "windows" ? "crlf" : "lf";
168+
}
169+
if (result.useTabs == null) {
170+
result.useTabs = !appSettings?.value?.softTab;
171+
}
172+
if (
173+
result.tabWidth == null &&
174+
typeof appSettings?.value?.tabSize === "number"
175+
) {
176+
result.tabWidth = appSettings.value.tabSize;
177+
}
178+
return result;
179+
}
180+
181+
async function loadProjectConfig(file) {
182+
const uri = file?.uri;
183+
if (!uri) return null;
184+
185+
const projectRoot = findProjectRoot(uri);
186+
const directories = collectCandidateDirectories(uri, projectRoot);
187+
188+
for (const directory of directories) {
189+
const config = await readConfigFromDirectory(directory);
190+
if (config) return config;
191+
}
192+
193+
return null;
194+
}
195+
196+
function findProjectRoot(uri) {
197+
const folders = Array.isArray(globalThis.addedFolder)
198+
? globalThis.addedFolder
199+
: [];
200+
const target = normalizePath(uri);
201+
let match = null;
202+
let matchLength = -1;
203+
204+
for (const folder of folders) {
205+
const folderUrl = folder?.url;
206+
if (!folderUrl) continue;
207+
const normalized = normalizePath(folderUrl);
208+
if (!normalized) continue;
209+
if (target === normalized || target.startsWith(`${normalized}/`)) {
210+
if (normalized.length > matchLength) {
211+
match = folderUrl;
212+
matchLength = normalized.length;
213+
}
214+
}
215+
}
216+
217+
return match;
218+
}
219+
220+
function collectCandidateDirectories(fileUri, projectRoot) {
221+
const directories = [];
222+
const visited = new Set();
223+
let currentDir = safeDirname(fileUri);
224+
225+
while (currentDir) {
226+
const normalized = normalizePath(currentDir);
227+
if (visited.has(normalized)) break;
228+
directories.push(currentDir);
229+
visited.add(normalized);
230+
if (projectRoot && pathsAreSame(currentDir, projectRoot)) break;
231+
const parent = safeDirname(currentDir);
232+
if (!parent || parent === currentDir) break;
233+
currentDir = parent;
234+
}
235+
236+
if (
237+
projectRoot &&
238+
!directories.some((dir) => pathsAreSame(dir, projectRoot))
239+
) {
240+
directories.push(projectRoot);
241+
}
242+
243+
return directories;
244+
}
245+
246+
function safeDirname(path) {
247+
try {
248+
return Url.dirname(path);
249+
} catch (_) {
250+
return null;
251+
}
252+
}
253+
254+
async function readConfigFromDirectory(directory) {
255+
if (!directory) return null;
256+
257+
for (const name of CONFIG_FILENAMES) {
258+
const config = await loadConfigFile(directory, name);
259+
if (config) return config;
260+
}
261+
262+
return loadPrettierFromPackageJson(directory);
263+
}
264+
265+
async function loadConfigFile(directory, basename) {
266+
try {
267+
const filePath = Url.join(directory, basename);
268+
const fs = fsOperation(filePath);
269+
if (!(await fs.exists())) return null;
270+
const text = await fs.readFile("utf8");
271+
272+
switch (basename) {
273+
case ".prettierrc":
274+
case ".prettierrc.json":
275+
case ".prettierrc.json5":
276+
case "prettier.config.json":
277+
return parseJsonLike(text);
278+
case ".prettierrc.js":
279+
case ".prettier.config.js":
280+
case "prettier.config.js":
281+
return parseJsConfig(directory, text, filePath);
282+
case ".prettierrc.mjs":
283+
case ".prettierrc.config.mjs":
284+
case ".prettier.config.mjs":
285+
case "prettier.config.mjs":
286+
return parseJsConfig(directory, text, filePath);
287+
case ".prettierrc.cjs":
288+
case ".prettierrc.config.cjs":
289+
case ".prettier.config.cjs":
290+
case "prettier.config.cjs":
291+
return parseJsConfig(directory, text, filePath);
292+
default:
293+
return null;
294+
}
295+
} catch (_) {
296+
return null;
297+
}
298+
}
299+
300+
async function loadPrettierFromPackageJson(directory) {
301+
try {
302+
const pkgPath = Url.join(directory, "package.json");
303+
const fs = fsOperation(pkgPath);
304+
if (!(await fs.exists())) return null;
305+
const pkg = await fs.readFile("json");
306+
const config = pkg?.prettier;
307+
if (config && typeof config === "object") return config;
308+
} catch (_) {
309+
return null;
310+
}
311+
return null;
312+
}
313+
314+
function parseJsonLike(text) {
315+
const trimmed = text?.trim();
316+
if (!trimmed) return null;
317+
const parsed = helpers.parseJSON(trimmed);
318+
if (parsed) return parsed;
319+
try {
320+
return new Function(`return (${trimmed});`)();
321+
} catch (_) {
322+
return null;
323+
}
324+
}
325+
326+
function parseJsConfig(directory, source, absolutePath) {
327+
if (!source) return null;
328+
void directory;
329+
void absolutePath;
330+
let transformed = source;
331+
if (/export\s+default/.test(transformed)) {
332+
transformed = transformed.replace(/export\s+default/, "module.exports =");
333+
}
334+
const module = { exports: {} };
335+
const exports = module.exports;
336+
function requireStub(request) {
337+
throw new Error(
338+
`require(\"${request}\") is not supported in Prettier configs inside Acode`,
339+
);
340+
}
341+
try {
342+
const fn = new Function("module", "exports", "require", transformed);
343+
fn(module, exports, requireStub);
344+
return module.exports ?? exports;
345+
} catch (_) {
346+
return null;
347+
}
348+
}
349+
350+
function normalizePath(path) {
351+
let result = String(path || "").replace(/\\/g, "/");
352+
while (result.length > 1 && result.endsWith("/")) {
353+
const prefix = result.slice(0, -1);
354+
if (/^[a-z]+:\/{0,2}$/i.test(prefix)) break;
355+
result = prefix;
356+
}
357+
return result;
358+
}
359+
360+
function pathsAreSame(a, b) {
361+
if (!a || !b) return false;
362+
return normalizePath(a) === normalizePath(b);
363+
}

src/lib/settings.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,7 @@ class Settings {
113113
autosave: 0,
114114
fileBrowser: this.#fileBrowserSettings,
115115
formatter: {},
116+
prettier: {},
116117
maxFileSize: 12,
117118
serverPort: constants.SERVER_PORT,
118119
previewPort: constants.PREVIEW_PORT,

src/main.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ import loadPlugins from "lib/loadPlugins";
3838
import Logger from "lib/logger";
3939
import NotificationManager from "lib/notificationManager";
4040
import openFolder, { addedFolder } from "lib/openFolder";
41+
import { registerPrettierFormatter } from "lib/prettierFormatter";
4142
import restoreFiles from "lib/restoreFiles";
4243
import settings from "lib/settings";
4344
import startAd from "lib/startAd";
@@ -213,6 +214,8 @@ async function onDeviceReady() {
213214
await settings.init();
214215
themes.init();
215216

217+
registerPrettierFormatter();
218+
216219
acode.setLoadingMessage("Loading language...");
217220
await lang.set(settings.value.lang);
218221

0 commit comments

Comments
 (0)