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 +248 -0
- package/dist/cli.js +532 -0
- package/dist/index.d.ts +52 -0
- package/dist/index.js +453 -0
- package/package.json +48 -0
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
|
+
});
|
package/dist/index.d.ts
ADDED
|
@@ -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
|
+
}
|