apotheke 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,248 @@
1
+ # apotheke
2
+
3
+ Import organizer for JavaScript and TypeScript projects. Groups, sorts, and deduplicates imports based on a simple config file.
4
+
5
+ ```ts
6
+ // Before
7
+ import { useQuery } from '@tanstack/react-query';
8
+ import { useMemo } from 'react';
9
+ import { createRoute } from '@tanstack/react-router';
10
+ import { tsr } from '../api/tsr';
11
+ import useLoggedUser from '../hooks/use-logged-user';
12
+
13
+ // After
14
+ // React
15
+ import { useMemo } from 'react';
16
+
17
+ // Hooks
18
+ import useLoggedUser from '../hooks/use-logged-user';
19
+
20
+ // Api
21
+ import { tsr } from '../api/tsr';
22
+ import { useQuery } from '@tanstack/react-query';
23
+
24
+ // Navigation
25
+ import { createRoute } from '@tanstack/react-router';
26
+ ```
27
+
28
+ ## Requirements
29
+
30
+ - Node.js ≥ 18
31
+
32
+ ## Installation
33
+
34
+ ```sh
35
+ npm install -D apotheke
36
+ # or
37
+ pnpm add -D apotheke
38
+ ```
39
+
40
+ ## Usage
41
+
42
+ ```sh
43
+ apotheke --write src/**/*.{ts,tsx} # format in place
44
+ apotheke --check src/**/*.{ts,tsx} # CI — exit 1 if anything would change
45
+ apotheke --diff src/**/*.{ts,tsx} # print diff without writing
46
+ apotheke --stdin-filepath src/app.tsx # read from stdin, write to stdout
47
+ ```
48
+
49
+ ## Config
50
+
51
+ Create `apotheke.config.mjs` at your project root:
52
+
53
+ ```js
54
+ // apotheke.config.mjs
55
+ export default {
56
+ groups: [
57
+ { name: 'React', match: ['react', 'react-dom', 'react-*'] },
58
+ { name: 'Hooks', match: ['**/hooks/**'] },
59
+ { name: 'Api', match: ['**/api/**', '@tanstack/react-query'] },
60
+ { name: 'Navigation', match: ['@tanstack/react-router'] },
61
+ { name: 'Assets', match: ['lucide-react'] }
62
+ ],
63
+ aliases: {
64
+ '@': './src' // mirrors tsconfig paths — auto-read if omitted
65
+ },
66
+ groupSeparator: true, // blank line between groups
67
+ groupComments: true // // GroupName header above each group
68
+ };
69
+ ```
70
+
71
+ Apotheke also reads `tsconfig.json` (or `jsconfig.json`) automatically to pick up `paths` aliases and `baseUrl`, so you often don't need to set `aliases` manually.
72
+
73
+ Unmatched imports collect in an **Others** group at the end. Side-effect imports (`import './styles.css'`) always go first.
74
+
75
+ ### Monorepo
76
+
77
+ Place a root config and extend it per-package:
78
+
79
+ ```js
80
+ // apps/web/apotheke.config.mjs
81
+ export default {
82
+ extends: '../../apotheke.config.mjs',
83
+ groups: [{ name: 'Shared', match: ['@acme/*'] }]
84
+ };
85
+ ```
86
+
87
+ ---
88
+
89
+ ## Prettier plugin
90
+
91
+ Apotheke ships as a prettier plugin. When loaded, it runs as a `preprocess` step — apotheke organises imports first, then prettier formats the result. One pass, correct order, no conflicts.
92
+
93
+ **Requirements:** Prettier v3 or later (v3 supports async `preprocess`; v2 does not).
94
+
95
+ ### Permanent setup
96
+
97
+ **1. Install apotheke**
98
+
99
+ ```sh
100
+ pnpm add -D apotheke
101
+ ```
102
+
103
+ **2. Add the plugin to your prettier config**
104
+
105
+ ```js
106
+ // .prettierrc.js
107
+ module.exports = {
108
+ plugins: ['apotheke'],
109
+ };
110
+ ```
111
+
112
+ That's it — `prettier --write` will now organise imports automatically.
113
+
114
+ ### Quick test (without installing)
115
+
116
+ ```sh
117
+ cd /path/to/your-project
118
+ npx prettier@3 \
119
+ --plugin /path/to/apotheke/dist/index.js \
120
+ --write 'src/App.tsx'
121
+ ```
122
+
123
+ ### VS Code on-save
124
+
125
+ Install the [Prettier - Code formatter](https://marketplace.visualstudio.com/items?itemName=esbenp.prettier-vscode) extension. Because `prettier.format()` calls our plugin's `preprocess` hook directly, no extra config is needed:
126
+
127
+ ```json
128
+ {
129
+ "editor.defaultFormatter": "esbenp.prettier-vscode",
130
+ "editor.formatOnSave": true
131
+ }
132
+ ```
133
+
134
+ ### Pre-commit with Husky + lint-staged
135
+
136
+ ```json
137
+ {
138
+ "lint-staged": {
139
+ "*.{ts,tsx,js,jsx}": ["prettier --write"]
140
+ }
141
+ }
142
+ ```
143
+
144
+ ### Prettier v2 fallback
145
+
146
+ If you can't upgrade to prettier v3, use the CLI sequentially via lint-staged:
147
+
148
+ ```json
149
+ {
150
+ "lint-staged": {
151
+ "*.{ts,tsx,js,jsx}": [
152
+ "apotheke --write",
153
+ "prettier --write"
154
+ ]
155
+ }
156
+ }
157
+ ```
158
+
159
+ ---
160
+
161
+ ## Testing locally without publishing
162
+
163
+ ### Option 1 — Direct invocation (no setup)
164
+
165
+ ```sh
166
+ node /path/to/apotheke/dist/cli.js --write 'src/**/*.{ts,tsx}'
167
+ ```
168
+
169
+ Build first if needed: `cd /path/to/apotheke && pnpm build`
170
+
171
+ ### Option 2 — `pnpm link` (recommended)
172
+
173
+ **In the apotheke repo:**
174
+
175
+ ```sh
176
+ pnpm build
177
+ pnpm link --global
178
+ ```
179
+
180
+ **In your target repo:**
181
+
182
+ ```sh
183
+ pnpm link --global apotheke
184
+ ```
185
+
186
+ Now `apotheke --write src/**/*.tsx` works as if it were installed normally. You'll need to re-run `pnpm build` in the apotheke repo after making source changes.
187
+
188
+ **To unlink when done:**
189
+
190
+ ```sh
191
+ # In your target repo
192
+ pnpm unlink --global apotheke
193
+
194
+ # In the apotheke repo
195
+ pnpm unlink --global
196
+ ```
197
+
198
+ ### Option 3 — Path dependency in `package.json`
199
+
200
+ ```sh
201
+ pnpm add /path/to/apotheke
202
+ ```
203
+
204
+ ---
205
+
206
+ ## Development
207
+
208
+ ```sh
209
+ pnpm install
210
+
211
+ # Build (required before running locally or testing the plugin)
212
+ pnpm build
213
+
214
+ # Run all tests (unit + e2e)
215
+ pnpm test
216
+
217
+ # Unit tests only
218
+ pnpm test:unit
219
+
220
+ # E2E tests against real repos (cloned automatically on first run)
221
+ pnpm test:e2e
222
+
223
+ # Type check
224
+ pnpm typecheck
225
+ ```
226
+
227
+ ### Project structure
228
+
229
+ ```
230
+ src/
231
+ parser.ts OXC-based import extractor
232
+ grouper.ts Glob-based group assignment
233
+ sorter.ts Alphabetical sort, type imports float to top
234
+ deduplicator.ts Merge named/default imports from same specifier
235
+ printer.ts Reconstruct import block with group headers
236
+ config.ts Load apotheke.config.mjs, merge tsconfig aliases
237
+ format.ts Top-level formatImports(source, config)
238
+ types.ts Shared types
239
+ cli.ts CLI source (Node.js)
240
+ index.ts Prettier plugin + programmatic API
241
+ dist/
242
+ cli.js Compiled CLI — run with node or via the apotheke bin
243
+ index.js Compiled prettier plugin
244
+ index.d.ts Types for programmatic use
245
+ tests/
246
+ unit/ 76 unit tests
247
+ e2e/ 20 e2e tests against sonner and tremor
248
+ ```
package/dist/cli.js ADDED
@@ -0,0 +1,532 @@
1
+ #!/usr/bin/env node
2
+
3
+ // cli.ts
4
+ import path3 from "path";
5
+ import fs from "fs";
6
+ import { execFileSync } from "child_process";
7
+ import fg from "fast-glob";
8
+
9
+ // src/config.ts
10
+ import path from "path";
11
+ import { existsSync, readFileSync } from "fs";
12
+ function mergeConfigs(parent, child) {
13
+ const mergedGroups = [...parent.groups];
14
+ for (const childGroup of child.groups ?? []) {
15
+ const existingIdx = mergedGroups.findIndex((g) => g.name === childGroup.name);
16
+ if (existingIdx >= 0) {
17
+ mergedGroups[existingIdx] = childGroup;
18
+ } else {
19
+ mergedGroups.push(childGroup);
20
+ }
21
+ }
22
+ return {
23
+ ...parent,
24
+ ...child,
25
+ groups: mergedGroups,
26
+ aliases: { ...parent.aliases ?? {}, ...child.aliases ?? {} }
27
+ };
28
+ }
29
+ async function loadConfig(configPath) {
30
+ const mod = await import(configPath);
31
+ const userConfig = mod.default ?? mod;
32
+ const configDir = path.dirname(configPath);
33
+ const config = { ...userConfig };
34
+ const tsconfig = loadTsConfig(configDir);
35
+ if (tsconfig) {
36
+ const opts = tsconfig.compilerOptions ?? {};
37
+ if (opts.baseUrl && !config.baseUrl) {
38
+ config.baseUrl = opts.baseUrl;
39
+ }
40
+ if (opts.paths) {
41
+ const aliases = { ...config.aliases ?? {} };
42
+ for (const [aliasPattern, targets] of Object.entries(
43
+ opts.paths
44
+ )) {
45
+ const alias = aliasPattern.replace(/\/\*$/, "");
46
+ const target = (targets[0] ?? "").replace(/\/\*$/, "");
47
+ if (!aliases[alias]) aliases[alias] = target;
48
+ }
49
+ config.aliases = aliases;
50
+ }
51
+ }
52
+ if (userConfig.extends) {
53
+ const parentPath = path.resolve(configDir, userConfig.extends);
54
+ if (!existsSync(parentPath)) {
55
+ throw new Error(
56
+ `apotheke: extended config not found: ${parentPath}
57
+ (referenced from ${configPath} via "extends": "${userConfig.extends}")`
58
+ );
59
+ }
60
+ const parent = await loadConfig(parentPath);
61
+ return mergeConfigs(parent, config);
62
+ }
63
+ return config;
64
+ }
65
+ function loadTsConfig(dir) {
66
+ const tsconfigPath = path.join(dir, "tsconfig.json");
67
+ if (!existsSync(tsconfigPath)) return null;
68
+ try {
69
+ const text = readFileSync(tsconfigPath, "utf8");
70
+ const cleaned = text.replace(/\/\/[^\n]*/g, "").replace(/\/\*[\s\S]*?\*\//g, "");
71
+ return JSON.parse(cleaned);
72
+ } catch {
73
+ return null;
74
+ }
75
+ }
76
+
77
+ // src/parser.ts
78
+ import { parseSync } from "oxc-parser";
79
+ function parseImports(source) {
80
+ const result = parseSync("file.tsx", source);
81
+ const comments = result.comments;
82
+ const nodes = [];
83
+ for (const node of result.program.body) {
84
+ if (node.type !== "ImportDeclaration") continue;
85
+ const specifier = node.source.value;
86
+ let defaultImport;
87
+ const namedImports = [];
88
+ let namespaceImport;
89
+ for (const s of node.specifiers) {
90
+ if (s.type === "ImportDefaultSpecifier") {
91
+ defaultImport = s.local.name;
92
+ } else if (s.type === "ImportNamespaceSpecifier") {
93
+ namespaceImport = s.local.name;
94
+ } else if (s.type === "ImportSpecifier") {
95
+ const imported = s.imported;
96
+ const name = imported.type === "Identifier" ? imported.name : imported.value;
97
+ const localName = s.local.name;
98
+ const kind = s.importKind === "type" ? "type" : "value";
99
+ const named = { name, kind };
100
+ if (localName !== name) named.alias = localName;
101
+ namedImports.push(named);
102
+ }
103
+ }
104
+ const importKind = node.importKind === "type" ? "type" : "value";
105
+ const isSideEffect = node.specifiers.length === 0 && importKind === "value";
106
+ const importNode = {
107
+ specifier,
108
+ namedImports,
109
+ isSideEffect,
110
+ importKind,
111
+ start: node.start,
112
+ end: node.end
113
+ };
114
+ if (defaultImport) importNode.defaultImport = defaultImport;
115
+ if (namespaceImport) importNode.namespaceImport = namespaceImport;
116
+ const attached = findAttachedComment(source, node.start, comments);
117
+ if (attached) importNode.attachedComment = attached;
118
+ nodes.push(importNode);
119
+ }
120
+ return nodes;
121
+ }
122
+ function findAttachedComment(source, importStart, comments) {
123
+ const linesBefore = source.slice(0, importStart).split("\n");
124
+ const importLine = linesBefore.length - 1;
125
+ for (const comment of comments) {
126
+ if (comment.type !== "Line") continue;
127
+ const commentLines = source.slice(0, comment.end).split("\n");
128
+ const commentLine = commentLines.length - 1;
129
+ if (commentLine === importLine - 1) {
130
+ return `//${comment.value}`;
131
+ }
132
+ }
133
+ return void 0;
134
+ }
135
+
136
+ // src/deduplicator.ts
137
+ function deduplicateImports(imports) {
138
+ const map = /* @__PURE__ */ new Map();
139
+ for (const node of imports) {
140
+ const key = `${node.importKind}::${node.specifier}`;
141
+ if (!map.has(key)) {
142
+ map.set(key, { ...node, namedImports: [...node.namedImports] });
143
+ continue;
144
+ }
145
+ const existing = map.get(key);
146
+ if (node.defaultImport && !existing.defaultImport) {
147
+ existing.defaultImport = node.defaultImport;
148
+ }
149
+ if (node.namespaceImport && !existing.namespaceImport) {
150
+ existing.namespaceImport = node.namespaceImport;
151
+ }
152
+ for (const named of node.namedImports) {
153
+ if (!existing.namedImports.some((n) => n.name === named.name && n.kind === named.kind)) {
154
+ existing.namedImports.push(named);
155
+ }
156
+ }
157
+ existing.isSideEffect = !existing.defaultImport && !existing.namespaceImport && existing.namedImports.length === 0;
158
+ }
159
+ return Array.from(map.values());
160
+ }
161
+
162
+ // src/grouper.ts
163
+ import path2 from "path";
164
+ function groupImports(imports, config, options = {}) {
165
+ const buckets = /* @__PURE__ */ new Map();
166
+ for (const node of imports) {
167
+ const groupName = assignGroup(node, config, options);
168
+ if (!buckets.has(groupName)) buckets.set(groupName, []);
169
+ buckets.get(groupName).push(node);
170
+ }
171
+ const result = [];
172
+ if (buckets.has("SideEffects")) {
173
+ result.push({ name: "SideEffects", imports: buckets.get("SideEffects") });
174
+ }
175
+ for (const group of config.groups) {
176
+ if (buckets.has(group.name)) {
177
+ result.push({ name: group.name, imports: buckets.get(group.name) });
178
+ }
179
+ }
180
+ if (buckets.has("Others")) {
181
+ result.push({ name: "Others", imports: buckets.get("Others") });
182
+ }
183
+ return result;
184
+ }
185
+ function assignGroup(node, config, options) {
186
+ if (node.isSideEffect) return "SideEffects";
187
+ const canonicalPath = resolveCanonicalPath(node.specifier, config, options);
188
+ for (const group of config.groups) {
189
+ for (const pattern of group.match) {
190
+ if (matches(node.specifier, pattern)) return group.name;
191
+ if (canonicalPath && matches(canonicalPath, pattern)) return group.name;
192
+ }
193
+ }
194
+ return "Others";
195
+ }
196
+ function resolveCanonicalPath(specifier, config, options) {
197
+ if (specifier.startsWith(".") || specifier.startsWith("/")) {
198
+ const base = options.fileDir ?? process.cwd();
199
+ return path2.resolve(base, specifier);
200
+ }
201
+ const aliases = config.aliases ?? {};
202
+ for (const [alias, target] of Object.entries(aliases)) {
203
+ const prefix = alias.endsWith("/") ? alias : `${alias}/`;
204
+ if (specifier === alias || specifier.startsWith(prefix)) {
205
+ const rest = specifier.slice(prefix.length);
206
+ const root = options.rootDir ?? process.cwd();
207
+ const resolved = path2.resolve(root, target, rest);
208
+ return resolved;
209
+ }
210
+ }
211
+ return null;
212
+ }
213
+ function matches(value, pattern) {
214
+ return globToRegex(pattern).test(value);
215
+ }
216
+ function globToRegex(pattern) {
217
+ let i = 0;
218
+ let re = "^";
219
+ while (i < pattern.length) {
220
+ const ch = pattern[i];
221
+ if (ch === "*" && pattern[i + 1] === "*") {
222
+ if (i === 0 && pattern[i + 2] === "/") {
223
+ re += "(.*\\/)?";
224
+ i += 3;
225
+ } else {
226
+ re += ".*";
227
+ i += 2;
228
+ if (pattern[i] === "/") i++;
229
+ }
230
+ } else if (ch === "*") {
231
+ re += "[^/]*";
232
+ i++;
233
+ } else if (ch === "?") {
234
+ re += "[^/]";
235
+ i++;
236
+ } else {
237
+ re += ch.replace(/[.+^${}()|[\]\\]/g, "\\$&");
238
+ i++;
239
+ }
240
+ }
241
+ return new RegExp(re + "$");
242
+ }
243
+
244
+ // src/sorter.ts
245
+ function sortGroup(imports) {
246
+ return [...imports].sort((a, b) => {
247
+ if (a.importKind === "type" && b.importKind !== "type") return -1;
248
+ if (a.importKind !== "type" && b.importKind === "type") return 1;
249
+ return a.specifier.localeCompare(b.specifier);
250
+ });
251
+ }
252
+ function sortNamedImports(node) {
253
+ const sorted = [...node.namedImports].sort((a, b) => {
254
+ if (a.kind === "type" && b.kind !== "type") return -1;
255
+ if (a.kind !== "type" && b.kind === "type") return 1;
256
+ return a.name.localeCompare(b.name);
257
+ });
258
+ return { ...node, namedImports: sorted };
259
+ }
260
+
261
+ // src/printer.ts
262
+ function detectQuoteChar(source) {
263
+ const m = source.match(/from\s*(["'])/);
264
+ return m?.[1] === '"' ? '"' : "'";
265
+ }
266
+ function printImportNode(node, q = "'") {
267
+ return buildImportStatement(node, q);
268
+ }
269
+ function printGroups(groups, config, q = "'") {
270
+ const useComments = config.groupComments !== false;
271
+ const useSeparator = config.groupSeparator !== false;
272
+ const parts = [];
273
+ for (const group of groups) {
274
+ const importLines = [];
275
+ for (let i = 0; i < group.imports.length; i++) {
276
+ const node = group.imports[i];
277
+ if (i === 0 && useComments) {
278
+ importLines.push(`// ${group.name}
279
+ ${buildImportStatement(node, q)}`);
280
+ continue;
281
+ }
282
+ importLines.push(printImportNode(node, q));
283
+ }
284
+ parts.push(importLines.join("\n"));
285
+ }
286
+ return parts.join(useSeparator ? "\n\n" : "\n");
287
+ }
288
+ function buildImportStatement(node, q) {
289
+ if (node.isSideEffect) {
290
+ return `import ${q}${node.specifier}${q};`;
291
+ }
292
+ const typePrefix = node.importKind === "type" ? "type " : "";
293
+ const parts = [];
294
+ if (node.defaultImport) {
295
+ parts.push(node.defaultImport);
296
+ }
297
+ if (node.namespaceImport) {
298
+ parts.push(`* as ${node.namespaceImport}`);
299
+ }
300
+ if (node.namedImports.length > 0) {
301
+ const named = node.namedImports.map((n) => {
302
+ const typePrefix2 = n.kind === "type" ? "type " : "";
303
+ return n.alias ? `${typePrefix2}${n.name} as ${n.alias}` : `${typePrefix2}${n.name}`;
304
+ }).join(", ");
305
+ parts.push(`{ ${named} }`);
306
+ }
307
+ return `import ${typePrefix}${parts.join(", ")} from ${q}${node.specifier}${q};`;
308
+ }
309
+
310
+ // src/format.ts
311
+ function collectOrphanSegments(source, imports) {
312
+ const orphans = [];
313
+ for (let i = 0; i < imports.length - 1; i++) {
314
+ const gapStart = imports[i].end;
315
+ const gapEnd = imports[i + 1].start;
316
+ if (gapEnd <= gapStart) continue;
317
+ let gap = source.slice(gapStart, gapEnd);
318
+ const nextComment = imports[i + 1].attachedComment;
319
+ if (nextComment) {
320
+ const commentText = nextComment.startsWith("//") ? nextComment.slice(2) : nextComment;
321
+ const commentPattern = `// ${commentText.trim()}`;
322
+ const idx = gap.lastIndexOf(commentPattern);
323
+ if (idx !== -1) gap = gap.slice(0, idx);
324
+ }
325
+ const trimmed = gap.trim();
326
+ if (trimmed) orphans.push(trimmed);
327
+ }
328
+ return orphans;
329
+ }
330
+ function formatImports(source, config, options = {}) {
331
+ const imports = parseImports(source);
332
+ if (imports.length === 0) return source;
333
+ const orphans = collectOrphanSegments(source, imports);
334
+ const clean = imports.map((n) => ({ ...n, attachedComment: void 0 }));
335
+ const deduped = deduplicateImports(clean);
336
+ const sorted = deduped.map(sortNamedImports);
337
+ const grouped = groupImports(sorted, config, options);
338
+ const sortedGroups = grouped.map((g) => ({ ...g, imports: sortGroup(g.imports) }));
339
+ const q = detectQuoteChar(source);
340
+ const newImportBlock = printGroups(sortedGroups, config, q);
341
+ const firstImport = imports[0];
342
+ const lastImport = imports[imports.length - 1];
343
+ let regionStart = firstImport.start;
344
+ if (firstImport.attachedComment) {
345
+ const before2 = source.slice(0, firstImport.start);
346
+ const commentLineStart = before2.lastIndexOf("\n", before2.length - 2) + 1;
347
+ regionStart = commentLineStart;
348
+ }
349
+ let regionEnd = lastImport.end;
350
+ if (source[regionEnd] === "\n") regionEnd++;
351
+ const before = source.slice(0, regionStart);
352
+ const after = source.slice(regionEnd);
353
+ const orphanSuffix = orphans.length > 0 ? "\n" + orphans.join("\n") + "\n" : "";
354
+ return before + newImportBlock + "\n" + orphanSuffix + after;
355
+ }
356
+
357
+ // cli.ts
358
+ var HELP = `
359
+ apotheke \u2014 JavaScript/TypeScript import organizer
360
+
361
+ Usage:
362
+ apotheke --write <files...> Organize imports in place
363
+ apotheke --check <files...> Exit 1 if any file would change (CI)
364
+ apotheke --diff <files...> Print what would change
365
+ apotheke --stdin-filepath <file> Read from stdin, write to stdout
366
+
367
+ Options:
368
+ --config <path> Path to config file (default: auto-discover apotheke.config.mjs/.js)
369
+ --help Show this help
370
+
371
+ Examples:
372
+ apotheke --write src/**/*.tsx
373
+ apotheke --check src/**/*.ts
374
+ echo "import..." | apotheke --stdin-filepath src/app.tsx
375
+ `.trim();
376
+ async function main() {
377
+ const args = process.argv.slice(2);
378
+ if (args.length === 0 || args.includes("--help")) {
379
+ console.log(HELP);
380
+ process.exit(0);
381
+ }
382
+ const mode = args.find((a) => ["--write", "--check", "--diff", "--stdin-filepath"].includes(a));
383
+ if (!mode) {
384
+ console.error("Error: specify --write, --check, --diff, or --stdin-filepath\n");
385
+ console.log(HELP);
386
+ process.exit(1);
387
+ }
388
+ const configIdx = args.indexOf("--config");
389
+ const explicitConfig = configIdx >= 0 ? args[configIdx + 1] : void 0;
390
+ if (mode === "--stdin-filepath") {
391
+ const filepath = args[args.indexOf("--stdin-filepath") + 1];
392
+ if (!filepath) {
393
+ console.error("Error: --stdin-filepath requires a file path argument");
394
+ process.exit(1);
395
+ }
396
+ const source = await readStdin();
397
+ const config = await resolveConfig(filepath, explicitConfig);
398
+ const result = formatImports(source, config, {
399
+ fileDir: path3.dirname(path3.resolve(filepath)),
400
+ rootDir: findRootDir(filepath)
401
+ });
402
+ process.stdout.write(result);
403
+ return;
404
+ }
405
+ const modeIdx = args.indexOf(mode);
406
+ const fileArgs = [];
407
+ for (let i = modeIdx + 1; i < args.length; i++) {
408
+ const arg = args[i];
409
+ if (arg === "--config") {
410
+ i++;
411
+ continue;
412
+ }
413
+ if (!arg.startsWith("--")) fileArgs.push(arg);
414
+ }
415
+ if (fileArgs.length === 0) {
416
+ console.error("Error: no files specified");
417
+ process.exit(1);
418
+ }
419
+ const files = await expandGlobs(fileArgs);
420
+ let anyChanged = false;
421
+ for (const file of files) {
422
+ const source = fs.readFileSync(file, "utf-8");
423
+ const config = await resolveConfig(file, explicitConfig);
424
+ const result = formatImports(source, config, {
425
+ fileDir: path3.dirname(path3.resolve(file)),
426
+ rootDir: findRootDir(file)
427
+ });
428
+ if (result === source) continue;
429
+ anyChanged = true;
430
+ if (mode === "--write") {
431
+ fs.writeFileSync(file, result, "utf-8");
432
+ console.log(` formatted ${file}`);
433
+ } else if (mode === "--diff") {
434
+ printDiff(file, source, result);
435
+ } else if (mode === "--check") {
436
+ console.log(` needs formatting: ${file}`);
437
+ }
438
+ }
439
+ if (mode === "--check" && anyChanged) process.exit(1);
440
+ }
441
+ async function readStdin() {
442
+ const chunks = [];
443
+ for await (const chunk of process.stdin) {
444
+ chunks.push(chunk);
445
+ }
446
+ return Buffer.concat(chunks).toString("utf-8");
447
+ }
448
+ async function resolveConfig(filePath, explicitConfig) {
449
+ if (explicitConfig) {
450
+ return loadConfig(path3.resolve(explicitConfig));
451
+ }
452
+ let dir = path3.dirname(path3.resolve(filePath));
453
+ while (true) {
454
+ for (const name of ["apotheke.config.mjs", "apotheke.config.js"]) {
455
+ const candidate = path3.join(dir, name);
456
+ if (fs.existsSync(candidate)) {
457
+ return loadConfig(candidate);
458
+ }
459
+ }
460
+ const parent = path3.dirname(dir);
461
+ if (parent === dir) break;
462
+ dir = parent;
463
+ }
464
+ throw new Error(
465
+ `apotheke: no config found for ${filePath}. Create an apotheke.config.mjs in your project root.`
466
+ );
467
+ }
468
+ function findRootDir(filePath) {
469
+ let dir = path3.dirname(path3.resolve(filePath));
470
+ while (true) {
471
+ if (fs.existsSync(path3.join(dir, "package.json"))) return dir;
472
+ const parent = path3.dirname(dir);
473
+ if (parent === dir) return dir;
474
+ dir = parent;
475
+ }
476
+ }
477
+ async function expandGlobs(patterns) {
478
+ const cwd = process.cwd();
479
+ const submoduleDirs = getSubmoduleDirs(cwd);
480
+ const files = [];
481
+ for (const pattern of patterns) {
482
+ if (!/[*?{[]/.test(pattern)) {
483
+ const abs = path3.resolve(pattern);
484
+ if (/\.(tsx?|jsx?)$/.test(abs) && fs.existsSync(abs) && !isUnderSubmodule(abs, submoduleDirs)) {
485
+ files.push(abs);
486
+ }
487
+ continue;
488
+ }
489
+ const matches2 = await fg(pattern, { cwd, onlyFiles: true, absolute: true });
490
+ for (const abs of matches2) {
491
+ if (/\.(tsx?|jsx?)$/.test(abs) && !isUnderSubmodule(abs, submoduleDirs)) {
492
+ files.push(abs);
493
+ }
494
+ }
495
+ }
496
+ return [...new Set(files)];
497
+ }
498
+ function getSubmoduleDirs(cwd) {
499
+ try {
500
+ const output = execFileSync("git", ["-C", cwd, "submodule", "foreach", "--recursive", "--quiet", "pwd"], {
501
+ encoding: "utf-8",
502
+ stdio: ["ignore", "pipe", "ignore"]
503
+ });
504
+ return output.trim().split("\n").filter(Boolean);
505
+ } catch {
506
+ return [];
507
+ }
508
+ }
509
+ function isUnderSubmodule(filePath, submoduleDirs) {
510
+ return submoduleDirs.some(
511
+ (dir) => filePath === dir || filePath.startsWith(dir + path3.sep)
512
+ );
513
+ }
514
+ function printDiff(file, original, updated) {
515
+ const origLines = original.split("\n");
516
+ const newLines = updated.split("\n");
517
+ console.log(`--- ${file}`);
518
+ console.log(`+++ ${file}`);
519
+ const maxLen = Math.max(origLines.length, newLines.length);
520
+ for (let i = 0; i < maxLen; i++) {
521
+ const o = origLines[i];
522
+ const n = newLines[i];
523
+ if (o !== n) {
524
+ if (o !== void 0) console.log(`- ${o}`);
525
+ if (n !== void 0) console.log(`+ ${n}`);
526
+ }
527
+ }
528
+ }
529
+ main().catch((err) => {
530
+ console.error(err.message);
531
+ process.exit(1);
532
+ });
@@ -0,0 +1,52 @@
1
+ interface GroupConfig {
2
+ name: string;
3
+ match: string[];
4
+ }
5
+ interface ApothekeConfig {
6
+ extends?: string;
7
+ groups: GroupConfig[];
8
+ aliases?: Record<string, string>;
9
+ baseUrl?: string;
10
+ normalizeImports?: 'alias' | 'relative' | 'absolute' | false;
11
+ groupSeparator?: boolean;
12
+ groupComments?: boolean;
13
+ }
14
+
15
+ interface FormatOptions {
16
+ fileDir?: string;
17
+ rootDir?: string;
18
+ }
19
+ declare function formatImports(source: string, config: ApothekeConfig, options?: FormatOptions): string;
20
+
21
+ /**
22
+ * Apotheke — prettier plugin + programmatic API
23
+ *
24
+ * Prettier plugin usage (.prettierrc):
25
+ * { "plugins": ["apotheke"] }
26
+ *
27
+ * Requirements: prettier v3+, Bun runtime
28
+ *
29
+ * The plugin runs as a `preprocess` step, so apotheke organises imports
30
+ * first and prettier formats the result — correct order, one pass.
31
+ *
32
+ * Pre-commit (lint-staged, prettier v2):
33
+ * { "*.{ts,tsx}": ["apotheke --write", "prettier --write"] }
34
+ */
35
+ type PrettierParser = {
36
+ preprocess?: (text: string, opts: {
37
+ filepath?: string;
38
+ }) => string | Promise<string>;
39
+ parse?: (text: string, options: unknown) => unknown;
40
+ astFormat?: string;
41
+ locStart?: (node: unknown) => number;
42
+ locEnd?: (node: unknown) => number;
43
+ [k: string]: unknown;
44
+ };
45
+ declare const parsers: {
46
+ typescript: PrettierParser;
47
+ babel: PrettierParser;
48
+ "babel-ts": PrettierParser;
49
+ "babel-flow": PrettierParser;
50
+ };
51
+
52
+ export { type ApothekeConfig, formatImports, parsers };
package/dist/index.js ADDED
@@ -0,0 +1,453 @@
1
+ // index.ts
2
+ import path3 from "path";
3
+ import { existsSync as existsSync2 } from "fs";
4
+
5
+ // src/parser.ts
6
+ import { parseSync } from "oxc-parser";
7
+ function parseImports(source) {
8
+ const result = parseSync("file.tsx", source);
9
+ const comments = result.comments;
10
+ const nodes = [];
11
+ for (const node of result.program.body) {
12
+ if (node.type !== "ImportDeclaration") continue;
13
+ const specifier = node.source.value;
14
+ let defaultImport;
15
+ const namedImports = [];
16
+ let namespaceImport;
17
+ for (const s of node.specifiers) {
18
+ if (s.type === "ImportDefaultSpecifier") {
19
+ defaultImport = s.local.name;
20
+ } else if (s.type === "ImportNamespaceSpecifier") {
21
+ namespaceImport = s.local.name;
22
+ } else if (s.type === "ImportSpecifier") {
23
+ const imported = s.imported;
24
+ const name = imported.type === "Identifier" ? imported.name : imported.value;
25
+ const localName = s.local.name;
26
+ const kind = s.importKind === "type" ? "type" : "value";
27
+ const named = { name, kind };
28
+ if (localName !== name) named.alias = localName;
29
+ namedImports.push(named);
30
+ }
31
+ }
32
+ const importKind = node.importKind === "type" ? "type" : "value";
33
+ const isSideEffect = node.specifiers.length === 0 && importKind === "value";
34
+ const importNode = {
35
+ specifier,
36
+ namedImports,
37
+ isSideEffect,
38
+ importKind,
39
+ start: node.start,
40
+ end: node.end
41
+ };
42
+ if (defaultImport) importNode.defaultImport = defaultImport;
43
+ if (namespaceImport) importNode.namespaceImport = namespaceImport;
44
+ const attached = findAttachedComment(source, node.start, comments);
45
+ if (attached) importNode.attachedComment = attached;
46
+ nodes.push(importNode);
47
+ }
48
+ return nodes;
49
+ }
50
+ function findAttachedComment(source, importStart, comments) {
51
+ const linesBefore = source.slice(0, importStart).split("\n");
52
+ const importLine = linesBefore.length - 1;
53
+ for (const comment of comments) {
54
+ if (comment.type !== "Line") continue;
55
+ const commentLines = source.slice(0, comment.end).split("\n");
56
+ const commentLine = commentLines.length - 1;
57
+ if (commentLine === importLine - 1) {
58
+ return `//${comment.value}`;
59
+ }
60
+ }
61
+ return void 0;
62
+ }
63
+
64
+ // src/deduplicator.ts
65
+ function deduplicateImports(imports) {
66
+ const map = /* @__PURE__ */ new Map();
67
+ for (const node of imports) {
68
+ const key = `${node.importKind}::${node.specifier}`;
69
+ if (!map.has(key)) {
70
+ map.set(key, { ...node, namedImports: [...node.namedImports] });
71
+ continue;
72
+ }
73
+ const existing = map.get(key);
74
+ if (node.defaultImport && !existing.defaultImport) {
75
+ existing.defaultImport = node.defaultImport;
76
+ }
77
+ if (node.namespaceImport && !existing.namespaceImport) {
78
+ existing.namespaceImport = node.namespaceImport;
79
+ }
80
+ for (const named of node.namedImports) {
81
+ if (!existing.namedImports.some((n) => n.name === named.name && n.kind === named.kind)) {
82
+ existing.namedImports.push(named);
83
+ }
84
+ }
85
+ existing.isSideEffect = !existing.defaultImport && !existing.namespaceImport && existing.namedImports.length === 0;
86
+ }
87
+ return Array.from(map.values());
88
+ }
89
+
90
+ // src/grouper.ts
91
+ import path from "path";
92
+ function groupImports(imports, config, options = {}) {
93
+ const buckets = /* @__PURE__ */ new Map();
94
+ for (const node of imports) {
95
+ const groupName = assignGroup(node, config, options);
96
+ if (!buckets.has(groupName)) buckets.set(groupName, []);
97
+ buckets.get(groupName).push(node);
98
+ }
99
+ const result = [];
100
+ if (buckets.has("SideEffects")) {
101
+ result.push({ name: "SideEffects", imports: buckets.get("SideEffects") });
102
+ }
103
+ for (const group of config.groups) {
104
+ if (buckets.has(group.name)) {
105
+ result.push({ name: group.name, imports: buckets.get(group.name) });
106
+ }
107
+ }
108
+ if (buckets.has("Others")) {
109
+ result.push({ name: "Others", imports: buckets.get("Others") });
110
+ }
111
+ return result;
112
+ }
113
+ function assignGroup(node, config, options) {
114
+ if (node.isSideEffect) return "SideEffects";
115
+ const canonicalPath = resolveCanonicalPath(node.specifier, config, options);
116
+ for (const group of config.groups) {
117
+ for (const pattern of group.match) {
118
+ if (matches(node.specifier, pattern)) return group.name;
119
+ if (canonicalPath && matches(canonicalPath, pattern)) return group.name;
120
+ }
121
+ }
122
+ return "Others";
123
+ }
124
+ function resolveCanonicalPath(specifier, config, options) {
125
+ if (specifier.startsWith(".") || specifier.startsWith("/")) {
126
+ const base = options.fileDir ?? process.cwd();
127
+ return path.resolve(base, specifier);
128
+ }
129
+ const aliases = config.aliases ?? {};
130
+ for (const [alias, target] of Object.entries(aliases)) {
131
+ const prefix = alias.endsWith("/") ? alias : `${alias}/`;
132
+ if (specifier === alias || specifier.startsWith(prefix)) {
133
+ const rest = specifier.slice(prefix.length);
134
+ const root = options.rootDir ?? process.cwd();
135
+ const resolved = path.resolve(root, target, rest);
136
+ return resolved;
137
+ }
138
+ }
139
+ return null;
140
+ }
141
+ function matches(value, pattern) {
142
+ return globToRegex(pattern).test(value);
143
+ }
144
+ function globToRegex(pattern) {
145
+ let i = 0;
146
+ let re = "^";
147
+ while (i < pattern.length) {
148
+ const ch = pattern[i];
149
+ if (ch === "*" && pattern[i + 1] === "*") {
150
+ if (i === 0 && pattern[i + 2] === "/") {
151
+ re += "(.*\\/)?";
152
+ i += 3;
153
+ } else {
154
+ re += ".*";
155
+ i += 2;
156
+ if (pattern[i] === "/") i++;
157
+ }
158
+ } else if (ch === "*") {
159
+ re += "[^/]*";
160
+ i++;
161
+ } else if (ch === "?") {
162
+ re += "[^/]";
163
+ i++;
164
+ } else {
165
+ re += ch.replace(/[.+^${}()|[\]\\]/g, "\\$&");
166
+ i++;
167
+ }
168
+ }
169
+ return new RegExp(re + "$");
170
+ }
171
+
172
+ // src/sorter.ts
173
+ function sortGroup(imports) {
174
+ return [...imports].sort((a, b) => {
175
+ if (a.importKind === "type" && b.importKind !== "type") return -1;
176
+ if (a.importKind !== "type" && b.importKind === "type") return 1;
177
+ return a.specifier.localeCompare(b.specifier);
178
+ });
179
+ }
180
+ function sortNamedImports(node) {
181
+ const sorted = [...node.namedImports].sort((a, b) => {
182
+ if (a.kind === "type" && b.kind !== "type") return -1;
183
+ if (a.kind !== "type" && b.kind === "type") return 1;
184
+ return a.name.localeCompare(b.name);
185
+ });
186
+ return { ...node, namedImports: sorted };
187
+ }
188
+
189
+ // src/printer.ts
190
+ function detectQuoteChar(source) {
191
+ const m = source.match(/from\s*(["'])/);
192
+ return m?.[1] === '"' ? '"' : "'";
193
+ }
194
+ function printImportNode(node, q = "'") {
195
+ return buildImportStatement(node, q);
196
+ }
197
+ function printGroups(groups, config, q = "'") {
198
+ const useComments = config.groupComments !== false;
199
+ const useSeparator = config.groupSeparator !== false;
200
+ const parts = [];
201
+ for (const group of groups) {
202
+ const importLines = [];
203
+ for (let i = 0; i < group.imports.length; i++) {
204
+ const node = group.imports[i];
205
+ if (i === 0 && useComments) {
206
+ importLines.push(`// ${group.name}
207
+ ${buildImportStatement(node, q)}`);
208
+ continue;
209
+ }
210
+ importLines.push(printImportNode(node, q));
211
+ }
212
+ parts.push(importLines.join("\n"));
213
+ }
214
+ return parts.join(useSeparator ? "\n\n" : "\n");
215
+ }
216
+ function buildImportStatement(node, q) {
217
+ if (node.isSideEffect) {
218
+ return `import ${q}${node.specifier}${q};`;
219
+ }
220
+ const typePrefix = node.importKind === "type" ? "type " : "";
221
+ const parts = [];
222
+ if (node.defaultImport) {
223
+ parts.push(node.defaultImport);
224
+ }
225
+ if (node.namespaceImport) {
226
+ parts.push(`* as ${node.namespaceImport}`);
227
+ }
228
+ if (node.namedImports.length > 0) {
229
+ const named = node.namedImports.map((n) => {
230
+ const typePrefix2 = n.kind === "type" ? "type " : "";
231
+ return n.alias ? `${typePrefix2}${n.name} as ${n.alias}` : `${typePrefix2}${n.name}`;
232
+ }).join(", ");
233
+ parts.push(`{ ${named} }`);
234
+ }
235
+ return `import ${typePrefix}${parts.join(", ")} from ${q}${node.specifier}${q};`;
236
+ }
237
+
238
+ // src/format.ts
239
+ function collectOrphanSegments(source, imports) {
240
+ const orphans = [];
241
+ for (let i = 0; i < imports.length - 1; i++) {
242
+ const gapStart = imports[i].end;
243
+ const gapEnd = imports[i + 1].start;
244
+ if (gapEnd <= gapStart) continue;
245
+ let gap = source.slice(gapStart, gapEnd);
246
+ const nextComment = imports[i + 1].attachedComment;
247
+ if (nextComment) {
248
+ const commentText = nextComment.startsWith("//") ? nextComment.slice(2) : nextComment;
249
+ const commentPattern = `// ${commentText.trim()}`;
250
+ const idx = gap.lastIndexOf(commentPattern);
251
+ if (idx !== -1) gap = gap.slice(0, idx);
252
+ }
253
+ const trimmed = gap.trim();
254
+ if (trimmed) orphans.push(trimmed);
255
+ }
256
+ return orphans;
257
+ }
258
+ function formatImports(source, config, options = {}) {
259
+ const imports = parseImports(source);
260
+ if (imports.length === 0) return source;
261
+ const orphans = collectOrphanSegments(source, imports);
262
+ const clean = imports.map((n) => ({ ...n, attachedComment: void 0 }));
263
+ const deduped = deduplicateImports(clean);
264
+ const sorted = deduped.map(sortNamedImports);
265
+ const grouped = groupImports(sorted, config, options);
266
+ const sortedGroups = grouped.map((g) => ({ ...g, imports: sortGroup(g.imports) }));
267
+ const q = detectQuoteChar(source);
268
+ const newImportBlock = printGroups(sortedGroups, config, q);
269
+ const firstImport = imports[0];
270
+ const lastImport = imports[imports.length - 1];
271
+ let regionStart = firstImport.start;
272
+ if (firstImport.attachedComment) {
273
+ const before2 = source.slice(0, firstImport.start);
274
+ const commentLineStart = before2.lastIndexOf("\n", before2.length - 2) + 1;
275
+ regionStart = commentLineStart;
276
+ }
277
+ let regionEnd = lastImport.end;
278
+ if (source[regionEnd] === "\n") regionEnd++;
279
+ const before = source.slice(0, regionStart);
280
+ const after = source.slice(regionEnd);
281
+ const orphanSuffix = orphans.length > 0 ? "\n" + orphans.join("\n") + "\n" : "";
282
+ return before + newImportBlock + "\n" + orphanSuffix + after;
283
+ }
284
+
285
+ // src/config.ts
286
+ import path2 from "path";
287
+ import { existsSync, readFileSync } from "fs";
288
+ function mergeConfigs(parent, child) {
289
+ const mergedGroups = [...parent.groups];
290
+ for (const childGroup of child.groups ?? []) {
291
+ const existingIdx = mergedGroups.findIndex((g) => g.name === childGroup.name);
292
+ if (existingIdx >= 0) {
293
+ mergedGroups[existingIdx] = childGroup;
294
+ } else {
295
+ mergedGroups.push(childGroup);
296
+ }
297
+ }
298
+ return {
299
+ ...parent,
300
+ ...child,
301
+ groups: mergedGroups,
302
+ aliases: { ...parent.aliases ?? {}, ...child.aliases ?? {} }
303
+ };
304
+ }
305
+ async function loadConfig(configPath) {
306
+ const mod = await import(configPath);
307
+ const userConfig = mod.default ?? mod;
308
+ const configDir = path2.dirname(configPath);
309
+ const config = { ...userConfig };
310
+ const tsconfig = loadTsConfig(configDir);
311
+ if (tsconfig) {
312
+ const opts = tsconfig.compilerOptions ?? {};
313
+ if (opts.baseUrl && !config.baseUrl) {
314
+ config.baseUrl = opts.baseUrl;
315
+ }
316
+ if (opts.paths) {
317
+ const aliases = { ...config.aliases ?? {} };
318
+ for (const [aliasPattern, targets] of Object.entries(
319
+ opts.paths
320
+ )) {
321
+ const alias = aliasPattern.replace(/\/\*$/, "");
322
+ const target = (targets[0] ?? "").replace(/\/\*$/, "");
323
+ if (!aliases[alias]) aliases[alias] = target;
324
+ }
325
+ config.aliases = aliases;
326
+ }
327
+ }
328
+ if (userConfig.extends) {
329
+ const parentPath = path2.resolve(configDir, userConfig.extends);
330
+ if (!existsSync(parentPath)) {
331
+ throw new Error(
332
+ `apotheke: extended config not found: ${parentPath}
333
+ (referenced from ${configPath} via "extends": "${userConfig.extends}")`
334
+ );
335
+ }
336
+ const parent = await loadConfig(parentPath);
337
+ return mergeConfigs(parent, config);
338
+ }
339
+ return config;
340
+ }
341
+ function loadTsConfig(dir) {
342
+ const tsconfigPath = path2.join(dir, "tsconfig.json");
343
+ if (!existsSync(tsconfigPath)) return null;
344
+ try {
345
+ const text = readFileSync(tsconfigPath, "utf8");
346
+ const cleaned = text.replace(/\/\/[^\n]*/g, "").replace(/\/\*[\s\S]*?\*\//g, "");
347
+ return JSON.parse(cleaned);
348
+ } catch {
349
+ return null;
350
+ }
351
+ }
352
+
353
+ // index.ts
354
+ import { createRequire } from "module";
355
+ async function resolveConfig(filePath) {
356
+ const candidates = ["apotheke.config.mjs", "apotheke.config.js"];
357
+ let dir = path3.dirname(path3.resolve(filePath));
358
+ while (true) {
359
+ for (const name of candidates) {
360
+ const candidate = path3.join(dir, name);
361
+ if (existsSync2(candidate)) return loadConfig(candidate);
362
+ }
363
+ const parent = path3.dirname(dir);
364
+ if (parent === dir) break;
365
+ dir = parent;
366
+ }
367
+ throw new Error(
368
+ `apotheke: no config found for ${filePath}. Create an apotheke.config.mjs in your project root.`
369
+ );
370
+ }
371
+ async function preprocess(text, options) {
372
+ try {
373
+ const config = await resolveConfig(options.filepath ?? process.cwd());
374
+ const fileDir = options.filepath ? path3.dirname(path3.resolve(options.filepath)) : void 0;
375
+ return formatImports(text, config, { fileDir });
376
+ } catch {
377
+ return text;
378
+ }
379
+ }
380
+ function resolveBaseParser(pluginPath, parserName, startDir) {
381
+ let dir = startDir;
382
+ while (true) {
383
+ try {
384
+ const req = createRequire(path3.join(dir, "__placeholder__.js"));
385
+ const mod = req(pluginPath);
386
+ const base = mod.parsers?.[parserName];
387
+ if (base) return base;
388
+ } catch {
389
+ }
390
+ const parent = path3.dirname(dir);
391
+ if (parent === dir) break;
392
+ dir = parent;
393
+ }
394
+ return null;
395
+ }
396
+ function makeParser(pluginPath, parserName) {
397
+ let cached = void 0;
398
+ function getBase(filepath) {
399
+ if (cached) return cached;
400
+ const roots = [
401
+ filepath ? path3.dirname(path3.resolve(filepath)) : null,
402
+ process.cwd()
403
+ ].filter(Boolean);
404
+ for (const root of roots) {
405
+ const found = resolveBaseParser(pluginPath, parserName, root);
406
+ if (found) {
407
+ cached = found;
408
+ return cached;
409
+ }
410
+ }
411
+ return null;
412
+ }
413
+ getBase();
414
+ return {
415
+ // astFormat must always be present so prettier can find its printer before
416
+ // parse() is called. "estree" is correct for all four parsers we expose.
417
+ astFormat: "estree",
418
+ // locStart/locEnd must match the base parser exactly. By the time prettier
419
+ // calls these, parse() has already run and populated `cached`, so we always
420
+ // delegate to the real implementations rather than approximating them.
421
+ locStart(node) {
422
+ return (cached?.locStart ?? ((n) => n.start))(node);
423
+ },
424
+ locEnd(node) {
425
+ return (cached?.locEnd ?? ((n) => n.end))(node);
426
+ },
427
+ parse(text, options) {
428
+ const base = getBase(options?.filepath);
429
+ if (base?.parse) {
430
+ return base.parse(text, options);
431
+ }
432
+ throw new Error(
433
+ `apotheke: could not resolve ${parserName} parser from prettier. Make sure prettier is installed in your project.`
434
+ );
435
+ },
436
+ async preprocess(text, opts) {
437
+ const base = getBase(opts?.filepath);
438
+ const basePreprocess = base?.preprocess;
439
+ const after = basePreprocess ? await basePreprocess(text, opts) : text;
440
+ return preprocess(after, opts);
441
+ }
442
+ };
443
+ }
444
+ var parsers = {
445
+ typescript: makeParser("prettier/plugins/typescript", "typescript"),
446
+ babel: makeParser("prettier/plugins/babel", "babel"),
447
+ "babel-ts": makeParser("prettier/plugins/babel", "babel-ts"),
448
+ "babel-flow": makeParser("prettier/plugins/babel", "babel-flow")
449
+ };
450
+ export {
451
+ formatImports,
452
+ parsers
453
+ };
package/package.json ADDED
@@ -0,0 +1,48 @@
1
+ {
2
+ "name": "apotheke",
3
+ "version": "0.0.1",
4
+ "description": "JavaScript/TypeScript import organizer — prettier plugin + CLI",
5
+ "type": "module",
6
+ "repository": {
7
+ "url": "https://github.com/mRaffaello/apotheke"
8
+ },
9
+ "exports": {
10
+ ".": "./dist/index.js"
11
+ },
12
+ "main": "./dist/index.js",
13
+ "types": "./dist/index.d.ts",
14
+ "bin": {
15
+ "apotheke": "./dist/cli.js"
16
+ },
17
+ "files": [
18
+ "dist"
19
+ ],
20
+ "devDependencies": {
21
+ "@commitlint/cli": "^20.5.3",
22
+ "@commitlint/config-conventional": "^20.5.3",
23
+ "@types/node": "^25.6.0",
24
+ "husky": "^9.1.7",
25
+ "prettier": "^3.8.3",
26
+ "tsup": "^8.5.1",
27
+ "vitest": "^4.1.5"
28
+ },
29
+ "peerDependencies": {
30
+ "prettier": ">=3"
31
+ },
32
+ "peerDependenciesMeta": {
33
+ "prettier": {
34
+ "optional": true
35
+ }
36
+ },
37
+ "dependencies": {
38
+ "fast-glob": "^3.3.3",
39
+ "oxc-parser": "^0.129.0"
40
+ },
41
+ "scripts": {
42
+ "build": "tsup",
43
+ "typecheck": "tsc --noEmit",
44
+ "test": "vitest run",
45
+ "test:unit": "vitest run tests/unit",
46
+ "test:e2e": "vitest run tests/e2e/e2e.test.ts"
47
+ }
48
+ }